feat: deliver the redesigned desktop workspace

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

Add safe history-folder reveal IPC, responsive 2560/1920/1366/1120 coverage, deterministic visual fixtures, focused component regressions, and release-tree exclusions for internal working files. Bump the public application version to 2.0.13.
This commit is contained in:
Sucukdeluxe
2026-08-10 14:15:57 +02:00
parent d844b33501
commit 069babfd54
77 changed files with 17442 additions and 3333 deletions
+104
View File
@@ -0,0 +1,104 @@
import { win32 } from "node:path";
import type { HistoryEntry, HistoryRevealResult } from "../shared/types";
export interface HistoryRevealRequest {
entryId: unknown;
}
export interface HistoryRevealStat {
isDirectory: () => boolean;
}
export interface HistoryRevealDependencies {
loadHistory: () => HistoryEntry[] | Promise<HistoryEntry[]>;
stat: (directory: string) => Promise<HistoryRevealStat>;
openPath: (directory: string) => Promise<string>;
}
function normalizeHistoryDirectory(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0 || /[\u0000-\u001f\u007f]/.test(value)) {
return null;
}
const candidate = value.replaceAll("/", "\\");
const lower = candidate.toLocaleLowerCase("en-US");
if (
lower.startsWith("\\\\.\\")
|| lower.startsWith("\\\\?\\")
|| lower.startsWith("\\??\\")
|| lower.startsWith("\\\\globalroot\\")
|| lower.startsWith("\\\\device\\")
) {
return null;
}
const driveAbsolute = /^[A-Za-z]:\\/.test(candidate);
const uncMatch = /^\\\\([^\\]+)\\([^\\]+)(?:\\.*)?$/.exec(candidate);
if (!driveAbsolute && !uncMatch) {
return null;
}
if (driveAbsolute) {
if (candidate.slice(2).includes(":")) {
return null;
}
} else if (candidate.includes(":")) {
return null;
}
if (/[<>|"?*]/.test(driveAbsolute ? candidate.slice(3) : candidate.slice(2))) {
return null;
}
if (uncMatch) {
const server = uncMatch[1].toLocaleLowerCase("en-US");
const share = uncMatch[2].toLocaleLowerCase("en-US");
if ([".", "..", "?", "globalroot", "device"].includes(server) || share === "." || share === "..") {
return null;
}
}
const normalized = win32.normalize(candidate);
if (/^[A-Za-z]:\\/.test(normalized)) {
return normalized;
}
if (/^\\\\[^\\]+\\[^\\]+(?:\\.*)?$/.test(normalized)) {
return normalized;
}
return null;
}
export async function revealHistoryEntry(
request: HistoryRevealRequest,
dependencies: HistoryRevealDependencies
): Promise<HistoryRevealResult> {
if (
typeof request.entryId !== "string"
|| request.entryId.length === 0
|| request.entryId.length > 256
|| request.entryId.trim() !== request.entryId
) {
return { ok: false, reason: "entry-not-found" };
}
const entries = await dependencies.loadHistory();
const entry = entries.find((candidate) => candidate.id === request.entryId);
if (!entry) {
return { ok: false, reason: "entry-not-found" };
}
const normalizedDirectory = normalizeHistoryDirectory(entry.outputDir);
if (!normalizedDirectory) {
return { ok: false, reason: "invalid-output-dir" };
}
let stat: HistoryRevealStat;
try {
stat = await dependencies.stat(normalizedDirectory);
} catch (error) {
const code = typeof error === "object" && error !== null && "code" in error
? (error as { code?: unknown }).code
: undefined;
return { ok: false, reason: code === "ENOENT" || code === "ENOTDIR" ? "output-dir-missing" : "open-failed" };
}
if (!stat.isDirectory()) {
return { ok: false, reason: "output-dir-not-directory" };
}
try {
const error = await dependencies.openPath(normalizedDirectory);
return error === "" ? { ok: true } : { ok: false, reason: "open-failed" };
} catch {
return { ok: false, reason: "open-failed" };
}
}
+13 -5
View File
@@ -9,7 +9,8 @@ import { getRecentErrors } from "./error-ring";
import { sendNotification } from "./notify";
import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
import { revealHistoryEntry } from "./history-reveal";
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
@@ -506,10 +507,17 @@ function registerIpcHandlers(): void {
});
ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory());
ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory());
ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => {
validateString(entryId, "entryId");
return controller.removeHistoryEntry(entryId);
});
ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => {
validateString(entryId, "entryId");
return controller.removeHistoryEntry(entryId);
});
ipcMain.handle(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => {
return revealHistoryEntry({ entryId }, {
loadHistory: () => controller.getHistory(),
stat: (directory) => fs.promises.stat(directory),
openPath: (directory) => shell.openPath(directory)
});
});
ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, async () => {
const options = {
defaultPath: `rd-queue-export.json`,
+6 -4
View File
@@ -8,7 +8,8 @@ import {
DebridProvider,
DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry,
HistoryEntry,
HistoryRevealResult,
PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport,
@@ -92,9 +93,10 @@ const api: ElectronApi = {
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
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),
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
+1990 -3050
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
import type { AudioStripSummary, DebridProvider } from "../shared/types";
export const providerLabels: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
megadebrid: "Mega-Debrid",
"megadebrid-api": "Mega-Debrid API",
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
linksnappy: "LinkSnappy"
};
export function compactProviderLabels(labels: string[]): string {
const groups = new Map<string, string[]>();
for (const label of [...new Set(labels)]) {
const match = label.match(/^(.+?)\s*\((.+)\)$/);
if (!match) {
groups.set(label, []);
continue;
}
const details = groups.get(match[1]) ?? [];
details.push(match[2]);
groups.set(match[1], details);
}
return [...groups].map(([base, details]) => details.length === 0 ? base : `${base} (${details.join(" + ")})`).join(", ");
}
export function formatDateTime(timestamp: number): string {
if (!timestamp) return "";
const date = new Date(timestamp);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${day}.${month}.${date.getFullYear()} - ${hours}:${minutes}`;
}
export function extractHoster(url: string): string {
try {
const host = new URL(url).hostname.replace(/^www\./, "");
const parts = host.split(".");
return parts.length >= 2 ? parts[parts.length - 2] : host;
} catch {
return "";
}
}
export function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } {
const parts: string[] = [];
const ok = summary.remuxed + summary.keptSingle;
if (ok > 0) parts.push(`${ok} OK`);
if (summary.skippedNoGerman > 0) parts.push(`${summary.skippedNoGerman} ohne DE-Tag`);
if (summary.skippedNoTool > 0) parts.push("ffmpeg fehlt");
if (summary.failed > 0) parts.push(`${summary.failed} Fehler`);
return {
text: `Tonspur: ${parts.join(" · ") || "—"}`,
tooltip: summary.files.map((entry) => `${entry.name}: ${entry.action} (${entry.reason}${entry.languages ? `, Spuren: ${entry.languages}` : ""})`).join("\n"),
attention: summary.skippedNoGerman > 0 || summary.skippedNoTool > 0 || summary.failed > 0
};
}
export function formatSpeedMbps(speedBps: number): string {
return `${(Math.max(0, speedBps || 0) / (1024 * 1024)).toFixed(2)} MB/s`;
}
export function humanSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(2)} MB`;
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
return `${(bytes / 1024 ** 4).toFixed(3)} TB`;
}
+37 -53
View File
@@ -39,56 +39,40 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
window.location.reload();
};
render(): React.ReactNode {
if (!this.state.hasError) {
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 (
<div style={overlay}>
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
</p>
<pre style={pre}>{this.state.message}</pre>
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
</div>
);
}
}
render(): React.ReactNode {
if (!this.state.hasError) {
return this.props.children;
}
return (
<div
className="ui-error-boundary"
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
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
</p>
<pre className="ui-error-boundary-details" id="renderer-error-details" tabIndex={0}>
{this.state.message}
</pre>
<button
autoFocus
className="ui-error-boundary-reload"
type="button"
onClick={this.handleReload}
>
Oberfläche neu laden
</button>
</div>
</div>
);
}
}
+4 -3
View File
@@ -1,8 +1,9 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { ErrorBoundary } from "./error-boundary";
import "./styles.css";
import { App } from "./App";
import { ErrorBoundary } from "./error-boundary";
import "./theme.css";
import "./styles.css";
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
// rejections) to the main process log. Without this, a renderer crash leaves no
+38
View File
@@ -0,0 +1,38 @@
import type { ReactElement, ReactNode } from "react";
import { Icon } from "../ui/Icon";
import { buildMainNavigation, type MainView } from "./shell-model";
export interface AppHeaderProps {
activeView: MainView;
onViewChange: (view: MainView) => void;
actions: ReactNode;
}
export function AppHeader({ activeView, onViewChange, actions }: AppHeaderProps): ReactElement {
const navigation = buildMainNavigation(activeView);
return (
<header className="md-shell-header" data-ui-region="header">
<div className="md-shell-brand">Multi-Debrid-Downloader</div>
<nav aria-label="Hauptnavigation" className="md-shell-navigation" role="tablist">
{navigation.map((item) => (
<button
aria-current={item.active ? "page" : undefined}
aria-selected={item.active}
className={`md-shell-navigation-item${item.active ? " is-active" : ""}`}
data-visual-active-view={item.active ? item.id : undefined}
key={item.id}
onClick={() => onViewChange(item.id)}
role="tab"
title={item.label}
type="button"
>
<Icon name={item.icon} size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
{actions ? <div aria-label="Globale Aktionen" className="md-shell-header-actions" role="group">{actions}</div> : null}
</header>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useSyncExternalStore, type ReactElement, type ReactNode } from "react";
import { ContextInfoButton } from "../ui/ContextInfoButton";
import { AppHeader } from "./AppHeader";
import { AppSidebar } from "./AppSidebar";
import {
getMainViewLabel,
getResponsiveShellMode,
type MainView,
type ResponsiveShellMode
} from "./shell-model";
import "./shell.css";
function subscribeToViewport(callback: () => void): () => void {
if (typeof window === "undefined") {
return () => undefined;
}
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
}
function getViewportShellMode(): ResponsiveShellMode {
return typeof window === "undefined" ? "full" : getResponsiveShellMode(window.innerWidth);
}
function getServerShellMode(): ResponsiveShellMode {
return "full";
}
export interface AppShellProps {
activeView: MainView;
onViewChange: (view: MainView) => void;
sidebar: ReactNode;
sidebarStatus: ReactNode;
headerActions: ReactNode;
toolbar: ReactNode;
children: ReactNode;
footer: ReactNode;
contextInfo: ReactNode;
sidebarCollapsed: boolean;
onSidebarCollapsedChange: (collapsed: boolean) => void;
}
export function AppShell({
activeView,
onViewChange,
sidebar,
sidebarStatus,
headerActions,
toolbar,
children,
footer,
contextInfo,
sidebarCollapsed,
onSidebarCollapsedChange
}: AppShellProps): ReactElement {
const [infoOpen, setInfoOpen] = useState(false);
const [responsiveSidebarExpanded, setResponsiveSidebarExpanded] = useState(false);
const responsiveMode = useSyncExternalStore(
subscribeToViewport,
getViewportShellMode,
getServerShellMode
);
const hasSidebar = Boolean(sidebar || sidebarStatus);
const responsiveSidebarCollapsed = responsiveMode !== "full" && !responsiveSidebarExpanded;
const effectiveSidebarCollapsed = sidebarCollapsed || responsiveSidebarCollapsed;
const setSidebarCollapsed = (collapsed: boolean): void => {
if (responsiveMode !== "full") {
setResponsiveSidebarExpanded(!collapsed);
}
onSidebarCollapsedChange(collapsed);
};
return (
<div
className={`md-shell is-${responsiveMode}${hasSidebar ? " has-sidebar" : ""}${hasSidebar && effectiveSidebarCollapsed ? " has-collapsed-sidebar" : ""}`}
data-responsive-mode={responsiveMode}
>
<AppHeader activeView={activeView} actions={headerActions} onViewChange={onViewChange} />
<div className="md-shell-workspace">
{hasSidebar ? (
<AppSidebar
collapsed={effectiveSidebarCollapsed}
onCollapsedChange={setSidebarCollapsed}
responsiveRail={responsiveSidebarCollapsed}
status={sidebarStatus}
>
{sidebar}
</AppSidebar>
) : null}
<section className="md-shell-main" data-ui-region="main">
{toolbar ? <div className="md-shell-toolbar" data-ui-region="toolbar">{toolbar}</div> : null}
<div className="md-shell-content">{children}</div>
{footer ? <div className="md-shell-footer" data-ui-region="footer">{footer}</div> : null}
<ContextInfoButton
content={contextInfo}
contextName={getMainViewLabel(activeView)}
onOpenChange={setInfoOpen}
open={infoOpen}
/>
</section>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ReactElement, ReactNode } from "react";
import { Icon } from "../ui/Icon";
export interface AppSidebarProps {
children: ReactNode;
status: ReactNode;
collapsed: boolean;
responsiveRail?: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}
export function AppSidebar({
children,
status,
collapsed,
responsiveRail = false,
onCollapsedChange
}: AppSidebarProps): ReactElement {
return (
<aside
className={`md-shell-sidebar${collapsed ? " is-collapsed" : ""}${responsiveRail ? " is-responsive-rail" : ""}`}
data-ui-region="sidebar"
>
{children ? <div className="md-shell-sidebar-scroll">{children}</div> : null}
{status ? <div className="md-shell-sidebar-status" data-ui-region="sidebar-status">{status}</div> : null}
<button
aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
className="md-shell-sidebar-toggle"
onClick={() => onCollapsedChange(!collapsed)}
title={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
type="button"
>
<Icon name={collapsed ? "chevron-right" : "chevron-left"} size={14} />
</button>
</aside>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, type ReactElement } from "react";
import { restoreFocus } from "../ui/focus";
export interface AvatarMenuAction {
id: string;
label: string;
danger?: boolean;
onSelect: () => void;
}
export interface AvatarMenuProps {
open: boolean;
accountLabel: string;
actions: AvatarMenuAction[];
onClose: () => void;
}
export type AvatarMenuKeyboardAction =
| { type: "close" }
| { type: "focus"; index: number };
export function getAvatarMenuKeyboardAction(
key: string,
currentIndex: number,
itemCount: number
): AvatarMenuKeyboardAction | null {
if (key === "Escape") {
return { type: "close" };
}
if (itemCount <= 0) {
return null;
}
if (key === "Home") {
return { type: "focus", index: 0 };
}
if (key === "End") {
return { type: "focus", index: itemCount - 1 };
}
if (key === "ArrowDown") {
return { type: "focus", index: (Math.max(currentIndex, -1) + 1) % itemCount };
}
if (key === "ArrowUp") {
return { type: "focus", index: currentIndex <= 0 ? itemCount - 1 : currentIndex - 1 };
}
return null;
}
export function AvatarMenu({ open, accountLabel, actions, onClose }: AvatarMenuProps): ReactElement | null {
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const closeAndRestoreFocus = (): void => {
const trigger = menuRef.current?.parentElement?.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
onClose();
restoreFocus(trigger ?? null);
};
useEffect(() => {
if (!open) {
return;
}
const onPointerDown = (event: MouseEvent): void => {
if (!menuRef.current?.parentElement?.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", onPointerDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
};
}, [onClose, open]);
if (!open) {
return null;
}
return (
<div
aria-label="Kontomenü"
className="md-avatar-menu"
onKeyDown={(event) => {
const currentIndex = itemRefs.current.findIndex((item) => item === document.activeElement);
const action = getAvatarMenuKeyboardAction(event.key, currentIndex, actions.length);
if (!action) {
return;
}
event.preventDefault();
event.stopPropagation();
if (action.type === "close") {
closeAndRestoreFocus();
return;
}
itemRefs.current[action.index]?.focus();
}}
ref={menuRef}
role="menu"
>
<div className="md-avatar-menu-account">{accountLabel}</div>
{actions.map((action, index) => (
<button
autoFocus={index === 0}
className={`md-avatar-menu-action${action.danger ? " is-danger" : ""}`}
key={action.id}
onClick={() => {
action.onSelect();
closeAndRestoreFocus();
}}
ref={(element) => {
itemRefs.current[index] = element;
}}
role="menuitem"
type="button"
>
{action.label}
</button>
))}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { ReactElement, ReactNode } from "react";
export interface OverlayHostProps {
confirm?: ReactNode;
onlineBackup?: ReactNode;
diagnostics?: ReactNode;
deleteConfirmation?: ReactNode;
conflict?: ReactNode;
accountCreate?: ReactNode;
accountEdit?: ReactNode;
keyStats?: ReactNode;
linkPopup?: ReactNode;
update?: ReactNode;
toast?: ReactNode;
accountContextMenu?: ReactNode;
downloadContextMenu?: ReactNode;
columnContextMenu?: ReactNode;
historyContextMenu?: ReactNode;
dropOverlay?: ReactNode;
}
export function OverlayHost({
confirm,
onlineBackup,
diagnostics,
deleteConfirmation,
conflict,
accountCreate,
accountEdit,
keyStats,
linkPopup,
update,
toast,
accountContextMenu,
downloadContextMenu,
columnContextMenu,
historyContextMenu,
dropOverlay
}: OverlayHostProps): ReactElement {
return (
<div className="md-overlay-host" id="md-overlay-host">
{confirm}
{onlineBackup}
{diagnostics}
{deleteConfirmation}
{conflict}
{accountCreate}
{accountEdit}
{keyStats}
{linkPopup}
{update}
{toast}
{accountContextMenu}
{downloadContextMenu}
{columnContextMenu}
{historyContextMenu}
{dropOverlay}
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useState, type ReactElement } from "react";
import type { UpdateInstallProgress } from "../../shared/types";
import { Dialog } from "../ui/Dialog";
export { getDialogFocusTarget as getUpdateDialogFocusTarget } from "../ui/Dialog";
export type UpdateExperienceState = "prompt" | UpdateInstallProgress["stage"];
export interface UpdateExperienceProgress {
percent: number | null;
text: string;
}
export interface UpdateExperienceProps {
available: boolean;
latestTag: string;
currentVersion: string;
releaseNotes: string;
state: UpdateExperienceState;
progress: UpdateExperienceProgress | number | null;
open: boolean;
onOpen: () => void;
onClose: () => void;
onInstall: () => void;
onLater: () => void;
renderTrigger?: boolean;
renderDialog?: boolean;
}
const activeStates = new Set<UpdateExperienceState>([
"starting",
"downloading",
"verifying",
"launching"
]);
export function UpdateExperience({
available,
latestTag,
currentVersion,
releaseNotes,
state,
progress,
open,
onOpen,
onClose,
onInstall,
onLater,
renderTrigger = true,
renderDialog = true
}: UpdateExperienceProps): ReactElement | null {
const [tooltipOpen, setTooltipOpen] = useState(false);
const progressInfo = typeof progress === "object" ? progress : null;
const active = activeStates.has(state);
const closable = !active;
if (!available && !open) {
return null;
}
return (
<>
{available && renderTrigger ? (
<div
className="md-update-anchor"
onBlur={() => setTooltipOpen(false)}
onFocus={() => setTooltipOpen(true)}
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
>
<button
aria-describedby="md-update-tooltip"
aria-label="Update verfügbar"
className="md-update-trigger"
onClick={onOpen}
type="button"
>
Update verfügbar
</button>
<div
aria-label="Update verfügbar"
className={`md-update-tooltip${tooltipOpen ? " is-visible" : ""}`}
id="md-update-tooltip"
role="tooltip"
>
Eine neue Version ist bereit. Klicke hier, um sie zu installieren.
</div>
</div>
) : null}
{renderDialog ? (
<Dialog
actions={state === "prompt" ? (
<>
<button className="btn" onClick={onLater} type="button">Später</button>
<button className="btn primary" onClick={onInstall} type="button">Jetzt aktualisieren</button>
</>
) : null}
actionsClassName="md-update-dialog-actions"
backdropClassName="md-update-backdrop"
bodyClassName="md-update-dialog-body"
className={`md-update-dialog md-update-dialog-${state}`}
closable={closable}
headerClassName="md-update-dialog-header"
onClose={onClose}
open={open}
showCloseButton
size="update"
title="Update installieren"
>
{state === "prompt" ? (
<>
<p>{latestTag} ist verfügbar. Installierte Version: {currentVersion}.</p>
{releaseNotes.trim() ? (
<details className="md-update-release-notes">
<summary>Changelog anzeigen</summary>
<pre>{releaseNotes}</pre>
</details>
) : null}
</>
) : (
<>
<p className="md-update-progress-text">{progressInfo?.text ?? "Update wird vorbereitet..."}</p>
{state === "downloading" && progressInfo?.percent !== null && progressInfo?.percent !== undefined ? (
<div
aria-label="Update-Fortschritt"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progressInfo.percent}
className="md-update-progress-track"
role="progressbar"
>
<div className="md-update-progress-fill" style={{ width: `${progressInfo.percent}%` }} />
</div>
) : null}
</>
)}
</Dialog>
) : null}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { IconName } from "../ui/Icon";
export type MainView = "downloads" | "collector" | "settings" | "history" | "statistics";
export type ResponsiveShellMode = "full" | "compact" | "minimum";
export interface NavigationItem {
id: MainView;
label: string;
icon: IconName;
active: boolean;
}
const MAIN_NAVIGATION = [
{ id: "downloads", label: "Downloads", icon: "download" },
{ id: "collector", label: "Linksammler", icon: "collector" },
{ id: "settings", label: "Einstellungen", icon: "settings" },
{ id: "history", label: "Verlauf", icon: "history" },
{ id: "statistics", label: "Statistiken", icon: "statistics" }
] as const;
export function buildMainNavigation(active: MainView): NavigationItem[] {
return MAIN_NAVIGATION.map((item) => ({ ...item, active: item.id === active }));
}
export function getMainViewLabel(view: MainView): string {
return MAIN_NAVIGATION.find((item) => item.id === view)?.label ?? view;
}
export function getResponsiveShellMode(width: number): ResponsiveShellMode {
if (width <= 1120) {
return "minimum";
}
if (width <= 1366) {
return "compact";
}
return "full";
}
+822
View File
@@ -0,0 +1,822 @@
.md-runtime-root {
--md-layer-avatar: 500;
--md-layer-menu: 600;
--md-layer-tooltip: 700;
--md-layer-toast: 800;
--md-layer-modal: 1000;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.md-runtime-root.settings-active {
user-select: none;
}
.md-runtime-root.settings-active input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
.md-runtime-root.settings-active textarea,
.md-runtime-root.settings-active [contenteditable]:not([contenteditable="false"]) {
user-select: text;
}
.md-shell {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
grid-template-rows: 40px minmax(0, 1fr);
gap: 8px;
padding: 8px 16px 16px;
overflow: hidden;
background: var(--ui-canvas);
color: var(--ui-text);
}
.md-shell-header {
position: relative;
display: flex;
min-width: 0;
height: 40px;
align-items: center;
gap: 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.md-shell-brand {
flex: 0 0 auto;
padding: 0 16px;
color: var(--ui-text);
font-size: 16px;
font-weight: 700;
line-height: 20px;
}
.md-shell-navigation {
display: flex;
min-width: 0;
flex: 1 1 auto;
height: 100%;
align-items: center;
gap: 2px;
overflow: hidden;
}
.md-shell-navigation-item {
display: inline-flex;
height: 32px;
padding: 0 10px;
align-items: center;
gap: 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font-weight: 600;
}
.md-shell-navigation-item:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-shell-navigation-item.is-active {
background: var(--ui-active);
color: var(--ui-text);
}
.md-shell-navigation-item:focus-visible,
.md-shell-header-actions button:focus-visible,
.md-shell-sidebar-toggle:focus-visible,
.md-avatar-menu-action:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.md-shell-header-actions {
position: relative;
display: flex;
min-width: 0;
flex: 0 0 auto;
height: 100%;
margin-left: auto;
align-items: center;
gap: 4px;
padding-right: 6px;
}
.md-update-anchor {
position: relative;
display: flex;
height: 100%;
align-items: center;
}
.md-update-trigger {
display: inline-flex;
height: 40px;
min-width: 90px;
padding: 0 14px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
background: var(--ui-primary);
color: #0f0f0f;
cursor: pointer;
font-size: 14px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
}
.md-update-trigger:hover {
background: var(--ui-primary-hover);
}
.md-update-trigger:focus-visible,
.md-update-dialog button:focus-visible,
.md-update-release-notes summary:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.md-update-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: var(--md-layer-tooltip);
display: flex;
width: 200px;
min-height: 68px;
padding: 12px;
align-items: center;
box-sizing: border-box;
border-radius: 8px;
background: #4f4d4d;
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: #fff;
font-size: 12px;
line-height: 16px;
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.md-update-anchor:hover .md-update-tooltip,
.md-update-anchor:focus-within .md-update-tooltip,
.md-update-tooltip.is-visible {
opacity: 1;
visibility: visible;
}
.md-update-backdrop {
position: fixed;
inset: 0;
z-index: 1300;
display: grid;
padding: 16px;
place-items: center;
background: rgb(0 0 0 / 60%);
}
.md-update-dialog {
display: grid;
width: min(548px, calc(100vw - 32px));
min-height: 207px;
max-height: calc(100vh - 32px);
grid-template-rows: 56px minmax(92px, auto) 58px;
overflow: auto;
transform: translateY(-24px);
border: 1px solid var(--ui-border);
border-radius: 16px;
background: var(--ui-surface);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
color: var(--ui-text);
}
.md-update-dialog:not(.md-update-dialog-prompt) {
grid-template-rows: 56px minmax(150px, auto);
}
.md-update-dialog:focus {
outline: none;
}
.md-update-dialog-header {
display: flex;
min-width: 0;
height: 56px;
padding: 0 16px 0 20px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--ui-border);
}
.md-update-dialog-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.md-update-dialog-close {
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font-size: 24px;
line-height: 24px;
}
.md-update-dialog-close:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-update-dialog-body {
min-width: 0;
padding: 16px 20px;
overflow: auto;
}
.md-update-dialog-body p {
margin: 0;
color: var(--ui-text-secondary);
line-height: 20px;
}
.md-update-release-notes {
margin-top: 10px;
color: var(--ui-text-secondary);
}
.md-update-release-notes summary {
width: max-content;
cursor: pointer;
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.md-update-release-notes pre {
margin: 10px 0 0;
color: var(--ui-text-secondary);
font: inherit;
line-height: 20px;
white-space: pre-wrap;
}
.md-update-dialog-actions {
display: flex;
min-height: 58px;
padding: 10px 16px;
align-items: center;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--ui-border);
}
.md-update-progress-text {
min-height: 20px;
}
.md-update-progress-track {
height: 8px;
margin-top: 16px;
overflow: hidden;
border-radius: 999px;
background: var(--ui-input);
}
.md-update-progress-fill {
height: 100%;
border-radius: inherit;
background: var(--ui-accent);
}
.md-application-menu-tree {
display: flex;
height: 100%;
align-items: center;
user-select: none;
}
.md-application-menu-tree .menu-bar-trigger {
height: 32px;
padding: 0 8px;
}
.md-download-sidebar-content {
display: grid;
gap: 12px;
}
.md-download-sidebar-search {
display: grid;
gap: 4px;
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.md-download-sidebar-search input {
width: 100%;
}
.md-download-sidebar-actions {
display: grid;
gap: 4px;
}
.md-download-sidebar-actions .btn {
width: 100%;
text-align: left;
}
.md-runtime-view-content {
display: flex;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.md-runtime-view-content > * {
width: 100%;
min-width: 0;
min-height: 0;
}
.md-shell-workspace {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.md-shell.has-sidebar .md-shell-workspace {
grid-template-columns: 270px minmax(0, 1fr);
}
.md-shell.has-collapsed-sidebar .md-shell-workspace {
grid-template-columns: 56px minmax(0, 1fr);
}
.md-shell-sidebar,
.md-shell-main {
position: relative;
min-width: 0;
min-height: 0;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.md-shell-sidebar {
display: flex;
flex-direction: column;
}
.md-shell-sidebar-scroll {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 12px;
}
.md-shell-sidebar-status {
display: grid;
flex: 0 0 auto;
gap: 4px;
padding: 12px;
border-top: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 12px;
line-height: 16px;
}
.md-shell-sidebar.is-collapsed :where(.md-shell-sidebar-scroll, .md-shell-sidebar-status) {
overflow: hidden;
visibility: hidden;
}
.md-shell-sidebar-toggle {
position: absolute;
top: 50%;
right: -8px;
z-index: 5;
display: grid;
width: 14px;
height: 42px;
padding: 0;
place-items: center;
transform: translateY(-50%);
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-input);
color: var(--ui-text-muted);
cursor: pointer;
opacity: 0;
}
.md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
top: 12px;
right: auto;
left: 11px;
width: 32px;
height: 32px;
transform: none;
opacity: 1;
}
.md-shell-sidebar:hover .md-shell-sidebar-toggle,
.md-shell-sidebar-toggle:focus-visible {
opacity: 1;
}
.md-shell-main {
display: flex;
overflow: hidden;
flex-direction: column;
}
.md-shell-toolbar {
min-width: 0;
flex: 0 0 auto;
overflow-x: auto;
overflow-y: hidden;
}
.md-shell-content {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.md-shell-footer {
flex: 0 0 auto;
border-top: 1px solid var(--ui-border);
}
.md-avatar-anchor {
position: relative;
}
.md-avatar-trigger {
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: var(--ui-active);
color: var(--ui-text-secondary);
cursor: pointer;
}
.md-avatar-menu {
position: absolute;
top: calc(100% + 5px);
right: 0;
z-index: var(--md-layer-avatar);
display: flex;
width: 174px;
min-height: 106px;
padding: 8px;
flex-direction: column;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.md-avatar-menu-account {
overflow: hidden;
padding: 4px 8px 8px;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-avatar-menu-action {
min-height: 30px;
padding: 5px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
text-align: left;
}
.md-avatar-menu-action:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-avatar-menu-action.is-danger {
color: var(--ui-danger);
}
.md-application-menu-tree :where(.menu-dropdown, .menu-submenu-dropdown) {
z-index: var(--md-layer-menu);
}
.md-overlay-host {
position: static;
}
.md-dialog-backdrop {
position: fixed;
inset: 0;
z-index: var(--md-layer-modal);
display: grid;
padding: 20px;
place-items: center;
background: var(--ui-overlay);
}
.md-dialog {
display: grid;
width: min(560px, calc(100vw - 40px));
max-height: calc(100vh - 40px);
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0;
overflow: hidden;
padding: 0;
border: 1px solid var(--ui-border);
border-radius: 16px;
outline: none;
background: var(--ui-surface);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
color: var(--ui-text);
}
.md-dialog.md-dialog-size-account {
width: min(660px, calc(100vw - 40px));
}
.md-dialog.md-dialog-size-wide {
width: min(760px, calc(100vw - 40px));
}
.md-dialog.md-dialog-size-update {
width: min(548px, calc(100vw - 32px));
}
.md-dialog-header {
display: flex;
min-width: 0;
min-height: 56px;
padding: 12px 16px 12px 20px;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--ui-border);
}
.md-dialog-header h2 {
margin: 0;
color: var(--ui-text);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.md-dialog-close {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
padding: 0;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font-size: 24px;
line-height: 24px;
}
.md-dialog-close:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-dialog-body {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 16px 20px;
}
.md-dialog-body > :first-child {
margin-top: 0;
}
.md-dialog-body > :last-child {
margin-bottom: 0;
}
.md-dialog-description,
.md-dialog-body p {
color: var(--ui-text-secondary);
}
.md-dialog-actions {
min-height: 58px;
padding: 10px 16px;
border-top: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.md-dialog :where(button, summary, input, select, textarea):focus-visible,
.md-context-menu [role="menuitem"]:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.md-context-menu {
z-index: var(--md-layer-menu);
max-width: calc(100vw - 8px);
max-height: calc(100vh - 8px);
overflow: auto;
border-color: var(--ui-border);
background: var(--ui-surface);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: var(--ui-text);
}
.md-context-menu .ctx-menu-item {
color: var(--ui-text);
}
.md-context-menu .ctx-menu-item:hover,
.md-context-menu .ctx-menu-item:focus-visible {
background: var(--ui-hover);
}
.md-context-menu .ctx-menu-item.ctx-danger {
color: var(--ui-danger);
}
.md-context-menu .ctx-menu-sep {
background: var(--ui-border);
}
.md-context-menu .ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items {
display: block;
}
.md-toast {
right: 20px;
bottom: 84px;
z-index: var(--md-layer-toast);
max-width: min(420px, calc(100vw - 40px));
border-color: var(--ui-border);
background: var(--ui-surface);
color: var(--ui-text);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
}
.md-drop-overlay {
z-index: var(--md-layer-modal);
pointer-events: none;
background: var(--ui-overlay);
border-color: color-mix(in srgb, var(--ui-accent) 72%, transparent);
color: var(--ui-text);
}
.md-overlay-host .md-dialog-backdrop {
z-index: var(--md-layer-modal);
padding: 20px;
background: var(--ui-overlay);
}
.md-overlay-host .md-dialog {
gap: 0;
overflow: hidden;
padding: 0;
border-color: var(--ui-border);
border-radius: 16px;
background: var(--ui-surface);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
color: var(--ui-text);
}
.md-overlay-host .md-context-menu {
z-index: var(--md-layer-menu);
border-color: var(--ui-border);
background: var(--ui-surface);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: var(--ui-text);
}
.md-overlay-host .md-toast {
bottom: 84px;
z-index: var(--md-layer-toast);
border-color: var(--ui-border);
background: var(--ui-surface);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: var(--ui-text);
}
.md-overlay-host .md-drop-overlay {
z-index: var(--md-layer-modal);
pointer-events: none;
background: var(--ui-overlay);
color: var(--ui-text);
}
@media (max-width: 1366px) {
.md-shell.is-compact .md-shell-navigation-item span,
.md-shell.is-compact .md-shell-brand,
.md-shell.is-minimum .md-shell-navigation-item span,
.md-shell.is-minimum .md-shell-brand {
display: none;
}
.md-shell.is-compact .md-shell-header,
.md-shell.is-minimum .md-shell-header {
gap: 6px;
}
.md-shell.is-compact .md-shell-navigation,
.md-shell.is-minimum .md-shell-navigation {
flex: 1 1 auto;
overflow: hidden;
}
.md-shell.is-compact .md-shell-navigation-item,
.md-shell.is-minimum .md-shell-navigation-item {
width: 36px;
flex: 0 0 36px;
padding-inline: 9px;
justify-content: center;
}
.md-shell.is-compact .md-shell-header-actions,
.md-shell.is-minimum .md-shell-header-actions {
flex: 0 0 auto;
}
}
@media (max-width: 1120px) {
.md-shell.is-minimum {
gap: 6px;
padding: 6px 8px 8px;
}
.md-shell.is-minimum .md-shell-workspace {
gap: 6px;
}
.md-dialog-backdrop,
.md-overlay-host .md-dialog-backdrop,
.md-update-backdrop {
padding: 12px;
}
.md-dialog,
.md-dialog.md-dialog-size-account,
.md-dialog.md-dialog-size-wide,
.md-dialog.md-dialog-size-update,
.md-update-dialog {
max-width: 100%;
max-height: calc(100vh - 24px);
}
.md-dialog-actions,
.md-update-dialog-actions {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
.md-shell *,
.md-shell *::before,
.md-shell *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+1 -196
View File
@@ -933,27 +933,6 @@ body,
padding: 7px 9px;
}
.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 {
display: flex;
align-items: center;
@@ -2221,8 +2200,7 @@ body,
white-space: nowrap;
}
.account-picker-icon,
.acct2-service-icon {
.account-picker-icon {
display: block;
width: 18px;
height: 18px;
@@ -2366,27 +2344,6 @@ body,
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"] {
cursor: grab;
}
@@ -2404,12 +2361,6 @@ body,
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 {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
@@ -2421,11 +2372,6 @@ body,
align-items: center;
}
.queue-package-card header {
min-height: 28px;
padding: 3px 10px;
}
.package-card h4 {
margin: 0;
font-size: 14px;
@@ -2460,15 +2406,6 @@ body,
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 {
border-color: var(--accent);
color: var(--text);
@@ -2516,13 +2453,6 @@ body,
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 {
height: 100%;
background: linear-gradient(90deg, #f2942d, #ff7a5c);
@@ -2566,10 +2496,6 @@ body,
margin-left: auto;
}
.history-card {
cursor: default;
}
.history-details {
padding: 10px 12px;
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);
}
.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 {
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
@@ -2780,16 +2696,6 @@ td {
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 {
grid-column: span 2;
}
@@ -2947,15 +2853,6 @@ td {
flex-direction: column;
}
.provider-stats {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.provider-stat-item {
display: flex;
flex-direction: column;
@@ -3014,11 +2911,6 @@ td {
}
@media (max-width: 1100px) {
.statistics-view {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.stats-overview {
grid-column: span 1;
}
@@ -3476,93 +3368,6 @@ td {
}
.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 {
width: min(600px, calc(100vw - 36px));
max-height: calc(100vh - 36px);
+392
View File
@@ -0,0 +1,392 @@
:root,
:root[data-theme="dark"] {
--ui-canvas: #0F0F0F;
--ui-surface: #232323;
--ui-input: #2B2B2B;
--ui-table-header: #313131;
--ui-active: #333436;
--ui-hover: #373535;
--ui-tooltip: #4F4D4D;
--ui-border: #3D3D3D;
--ui-text: #FFFFFF;
--ui-text-secondary: #EAEDF3;
--ui-text-muted: #919191;
--ui-primary: #BAD0FC;
--ui-primary-hover: #8AA5DC;
--ui-accent: #3886FF;
--ui-warning: #F1C786;
--ui-danger: #F06464;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #35383D;
--ui-overlay: rgba(0, 0, 0, 0.60);
color-scheme: dark;
}
:root[data-theme="light"] {
--ui-canvas: #F3F4F6;
--ui-surface: #FFFFFF;
--ui-input: #F7F8FA;
--ui-table-header: #E7E9ED;
--ui-active: #DEE6F5;
--ui-hover: #E8ECF3;
--ui-tooltip: #35383D;
--ui-border: #D0D4DB;
--ui-text: #181A1F;
--ui-text-secondary: #343842;
--ui-text-muted: #667085;
--ui-primary: #A9C2F3;
--ui-primary-hover: #8AA5DC;
--ui-accent: #256FDB;
--ui-warning: #E8B85D;
--ui-danger: #D94747;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #E7E9ED;
--ui-overlay: rgba(0, 0, 0, 0.45);
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 0;
min-height: 100%;
}
body {
margin: 0;
background: var(--ui-canvas);
color: var(--ui-text);
font-family: Inter, "Segoe UI Variable", "Segoe UI", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
text-rendering: optimizeLegibility;
user-select: none;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
color: inherit;
}
input,
textarea,
[contenteditable="true"] {
user-select: text;
}
* {
scrollbar-color: var(--ui-border) transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: var(--ui-border);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
.ui-icon {
display: block;
flex: 0 0 auto;
pointer-events: none;
}
.ui-input {
min-width: 0;
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 6px;
outline: none;
background: var(--ui-input);
color: var(--ui-text);
}
.ui-input::placeholder {
color: var(--ui-text-muted);
opacity: 1;
}
.ui-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
:where(
button,
[href],
input,
select,
textarea,
summary,
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.ui-toolbar {
display: flex;
min-width: 0;
min-height: 59px;
align-items: center;
gap: 8px;
padding: 11px 12px 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.ui-toolbar-group {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.ui-toolbar-search {
position: relative;
display: flex;
width: min(280px, 100%);
margin-left: auto;
align-items: center;
}
.ui-toolbar-search-icon {
position: absolute;
left: 10px;
color: var(--ui-text-muted);
}
.ui-toolbar-search-input {
width: 100%;
padding: 7px 12px 7px 34px;
}
.ui-data-table {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
background: var(--ui-surface);
color: var(--ui-text);
}
.ui-data-table-header {
min-height: 41px;
flex: 0 0 41px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
}
.ui-data-table-body {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: auto;
}
.ui-data-table-empty {
display: flex;
min-height: 100%;
flex: 1 1 auto;
}
.ui-data-table-empty-cell {
display: flex;
max-width: 440px;
margin: auto;
align-items: center;
flex-direction: column;
gap: 8px;
padding: 32px 24px;
color: var(--ui-text-muted);
text-align: center;
}
.ui-data-table-empty-illustration {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 4px;
place-items: center;
color: var(--ui-text-secondary);
}
.ui-data-table-empty-title {
color: var(--ui-text);
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.ui-data-table-empty-description {
color: var(--ui-text-muted);
}
.ui-data-table-footer {
display: flex;
min-height: 61px;
flex: 0 0 61px;
align-items: center;
justify-content: flex-end;
gap: 24px;
padding: 0 16px;
border-top: 1px solid var(--ui-border);
background: var(--ui-surface);
color: var(--ui-text-secondary);
font-size: 12px;
line-height: 16px;
}
.ui-context-info {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 20;
display: flex;
align-items: flex-end;
gap: 8px;
}
.ui-context-info-trigger {
display: inline-grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-surface);
color: var(--ui-text-secondary);
cursor: pointer;
}
.ui-context-info-trigger:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.ui-context-info-region {
width: min(360px, calc(100vw - 80px));
max-height: min(320px, calc(100vh - 96px));
overflow: auto;
padding: 12px 16px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-tooltip);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
color: #FFFFFF;
}
.ui-error-boundary {
position: fixed;
inset: 0;
z-index: 10000;
overflow: auto;
padding: clamp(24px, 5vw, 48px);
background: var(--ui-canvas);
color: var(--ui-text);
text-align: center;
}
.ui-error-boundary-content {
display: flex;
width: min(640px, 100%);
min-height: 100%;
margin: auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.ui-error-boundary-title {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.ui-error-boundary-description {
max-width: 560px;
margin: 0;
color: var(--ui-text-secondary);
}
.ui-error-boundary-details {
width: 100%;
max-height: min(200px, 40vh);
margin: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-danger);
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
text-align: left;
user-select: text;
white-space: pre-wrap;
}
.ui-error-boundary-reload {
min-height: 40px;
padding: 8px 20px;
border: 0;
border-radius: 6px;
background: var(--ui-primary);
color: var(--ui-error-action-text);
cursor: pointer;
font-weight: 600;
}
.ui-error-boundary-reload:hover {
background: var(--ui-primary-hover);
}
.ui-error-boundary-details:focus-visible,
.ui-error-boundary-reload:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Children, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from "react";
import { Icon } from "./Icon";
export interface ContextInfoButtonProps {
contextName: string;
content: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function hasRenderableContent(content: ReactNode): boolean {
if (content === null || content === undefined || typeof content === "boolean") {
return false;
}
if (typeof content === "string") {
return content.trim().length > 0;
}
if (typeof content === "number") {
return true;
}
if (Array.isArray(content)) {
return content.some(hasRenderableContent);
}
if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) {
return hasRenderableContent(content.props.children);
}
if (isValidElement(content)) {
return true;
}
return Children.toArray(content).some(hasRenderableContent);
}
export function ContextInfoButton({
contextName,
content,
open,
onOpenChange
}: ContextInfoButtonProps): ReactElement | null {
const regionId = useId();
if (!hasRenderableContent(content)) {
return null;
}
return (
<div className="ui-context-info">
<button
aria-controls={regionId}
aria-expanded={open}
aria-label="Informationen"
className="ui-context-info-trigger"
onClick={() => onOpenChange(!open)}
title="Informationen"
type="button"
>
<Icon name="info" size={18} />
</button>
{open ? (
<div
aria-label={`Informationen zu ${contextName}`}
className="ui-context-info-region"
id={regionId}
role="region"
>
{content}
</div>
) : null}
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject
} from "react";
import { restoreFocus } from "./focus";
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
export interface ContextMenuProps {
open: boolean;
x: number;
y: number;
onClose: () => void;
children: ReactNode;
ariaLabel?: string;
className?: string;
ignoreOutsideRefs?: Array<RefObject<HTMLElement>>;
}
export type ContextMenuKeyboardAction =
| { type: "focus"; index: number }
| { type: "activate"; index: number }
| { type: "close" };
export type ContextMenuSubmenuKeyboardAction = "open" | "close";
export function clampContextMenuPosition(
x: number,
y: number,
width: number,
height: number,
viewportWidth: number,
viewportHeight: number
): { x: number; y: number } {
return {
x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))),
y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height)))
};
}
export function getContextSubmenuPosition(
trigger: { left: number; right: number; top: number },
submenu: { width: number; height: number },
viewport: { width: number; height: number }
): { x: number; y: number } {
const opensRight = trigger.right + submenu.width <= viewport.width || trigger.left - submenu.width < 0;
return clampContextMenuPosition(
opensRight ? trigger.right : trigger.left - submenu.width,
trigger.top,
submenu.width,
submenu.height,
viewport.width,
viewport.height
);
}
export function getContextMenuKeyboardAction(
key: string,
currentIndex: number,
enabled: boolean[]
): ContextMenuKeyboardAction | null {
const indexes = enabled.flatMap((value, index) => value ? [index] : []);
if (key === "Escape") {
return { type: "close" };
}
if (indexes.length === 0) {
return null;
}
if (key === "Enter" || key === " ") {
return { type: "activate", index: enabled[currentIndex] ? currentIndex : indexes[0] };
}
if (key === "Home") {
return { type: "focus", index: indexes[0] };
}
if (key === "End") {
return { type: "focus", index: indexes[indexes.length - 1] };
}
if (key !== "ArrowDown" && key !== "ArrowUp") {
return null;
}
const enabledPosition = indexes.indexOf(currentIndex);
if (enabledPosition < 0) {
return { type: "focus", index: key === "ArrowDown" ? indexes[0] : indexes[indexes.length - 1] };
}
const direction = key === "ArrowDown" ? 1 : -1;
const nextPosition = (enabledPosition + direction + indexes.length) % indexes.length;
return { type: "focus", index: indexes[nextPosition] };
}
export function getContextMenuSubmenuKeyboardAction(
key: string,
hasSubmenu: boolean,
insideSubmenu: boolean
): ContextMenuSubmenuKeyboardAction | null {
if (hasSubmenu && (key === "Enter" || key === "ArrowRight")) {
return "open";
}
if (insideSubmenu && (key === "ArrowLeft" || key === "Escape")) {
return "close";
}
return null;
}
function applyMenuItemSemantics(node: ReactNode): ReactNode {
return Children.map(node, (child) => {
if (!isValidElement(child)) {
return child;
}
const element = child as ReactElement<{
children?: ReactNode;
disabled?: boolean;
role?: string;
tabIndex?: number;
}>;
if (typeof element.type === "string" && element.type === "button") {
return cloneElement(element, { role: "menuitem", tabIndex: -1 });
}
if (element.props.children === undefined) {
return element;
}
return cloneElement(element, { children: applyMenuItemSemantics(element.props.children) });
});
}
function getMenuItems(menu: HTMLElement | null): HTMLElement[] {
return Array.from(menu?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? []).filter((item) => {
if (item.matches(":disabled") || item.getAttribute("aria-disabled") === "true") {
return false;
}
return item.getClientRects().length > 0;
});
}
function getTopLevelMenuItems(menu: HTMLElement | null): HTMLElement[] {
return getMenuItems(menu).filter((item) => !item.closest(".ctx-menu-sub-items"));
}
function getSubmenuParts(item: HTMLElement | null): {
container: HTMLElement;
trigger: HTMLElement;
items: HTMLElement;
} | null {
const container = item?.closest<HTMLElement>(".ctx-menu-sub") ?? null;
if (!container) {
return null;
}
const trigger = Array.from(container.children).find((child) => child.matches("[role='menuitem']"));
const items = Array.from(container.children).find((child) => child.matches(".ctx-menu-sub-items"));
if (!(trigger instanceof HTMLElement) || !(items instanceof HTMLElement)) {
return null;
}
return { container, trigger, items };
}
function openSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
if (!parts) {
return;
}
parts.container.classList.add("is-keyboard-open");
parts.trigger.setAttribute("aria-expanded", "true");
positionSubmenu(parts);
getMenuItems(parts.items)[0]?.focus();
}
function positionSubmenu(parts: NonNullable<ReturnType<typeof getSubmenuParts>>): void {
const triggerRect = parts.trigger.getBoundingClientRect();
const submenuRect = parts.items.getBoundingClientRect();
const position = getContextSubmenuPosition(
triggerRect,
submenuRect,
{ width: window.innerWidth, height: window.innerHeight }
);
parts.items.style.position = "fixed";
parts.items.style.left = `${position.x}px`;
parts.items.style.top = `${position.y}px`;
}
function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
if (!parts) {
return;
}
parts.container.classList.remove("is-keyboard-open");
parts.trigger.setAttribute("aria-expanded", "false");
parts.trigger.focus();
}
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function ContextMenu({
open,
x,
y,
onClose,
children,
ariaLabel = "Kontextmenü",
className = "",
ignoreOutsideRefs = []
}, forwardedRef): ReactElement | null {
const menuRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs);
const [position, setPosition] = useState({ x, y });
onCloseRef.current = onClose;
ignoreOutsideRefsRef.current = ignoreOutsideRefs;
useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement);
useImmediateEffect(() => {
if (!open || !menuRef.current) {
return;
}
if (!previousFocusRef.current) {
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
const rect = menuRef.current.getBoundingClientRect();
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
setPosition((current) => current.x === next.x && current.y === next.y ? current : next);
getTopLevelMenuItems(menuRef.current)[0]?.focus();
}, [open, x, y]);
useEffect(() => {
if (!open) {
return;
}
const onOutside = (event: MouseEvent): void => {
const target = event.target;
if (!(target instanceof Node) || menuRef.current?.contains(target)) {
return;
}
if (ignoreOutsideRefsRef.current.some((ref) => ref.current?.contains(target))) {
return;
}
onCloseRef.current();
};
window.addEventListener("mousedown", onOutside);
window.addEventListener("contextmenu", onOutside);
return () => {
window.removeEventListener("mousedown", onOutside);
window.removeEventListener("contextmenu", onOutside);
const previousFocus = previousFocusRef.current;
previousFocusRef.current = null;
restoreFocus(previousFocus);
};
}, [open]);
if (!open) {
return null;
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const activeItem = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const submenu = getSubmenuParts(activeItem);
const insideSubmenu = Boolean(activeItem?.closest(".ctx-menu-sub-items"));
const hasSubmenu = submenu?.trigger === activeItem;
const submenuAction = getContextMenuSubmenuKeyboardAction(event.key, hasSubmenu, insideSubmenu);
if (submenuAction) {
event.preventDefault();
event.stopPropagation();
if (submenuAction === "open") {
openSubmenu(submenu);
} else {
closeSubmenu(submenu);
}
return;
}
const submenuItems = insideSubmenu ? activeItem?.closest<HTMLElement>(".ctx-menu-sub-items") ?? null : null;
const items = submenuItems ? getMenuItems(submenuItems) : getTopLevelMenuItems(menuRef.current);
const currentIndex = items.findIndex((item) => item === document.activeElement);
const action = getContextMenuKeyboardAction(event.key, currentIndex, items.map(() => true));
if (!action) {
return;
}
event.preventDefault();
event.stopPropagation();
if (action.type === "close") {
onClose();
return;
}
if (action.type === "activate") {
items[action.index]?.click();
return;
}
items[action.index]?.focus();
};
return (
<div
aria-label={ariaLabel}
className={["ctx-menu", "md-context-menu", className].filter(Boolean).join(" ")}
onClick={(event) => {
event.stopPropagation();
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
const submenu = getSubmenuParts(item);
if (submenu?.trigger === item) {
event.preventDefault();
openSubmenu(submenu);
}
}}
onKeyDown={onKeyDown}
onMouseOver={(event) => {
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
const submenu = getSubmenuParts(item);
if (submenu?.trigger === item) {
positionSubmenu(submenu);
}
}}
ref={menuRef}
role="menu"
style={{ left: position.x, top: position.y }}
>
{applyMenuItemSemantics(children)}
</div>
);
});
+104
View File
@@ -0,0 +1,104 @@
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
export interface DataTableProps extends HTMLAttributes<HTMLDivElement> {
label?: string;
}
export type DataTableSectionProps = HTMLAttributes<HTMLDivElement>;
export interface DataTableEmptyProps extends HTMLAttributes<HTMLDivElement> {
title: string;
description?: string;
illustration?: ReactNode;
}
export interface DataTableFooterProps extends HTMLAttributes<HTMLDivElement> {
pageSize: number;
rangeLabel: string;
paginationVisible: boolean;
}
export function DataTable({ label = "Datentabelle", className, children, ...props }: DataTableProps): ReactElement {
return (
<div
{...props}
aria-label={label}
className={["ui-data-table", className].filter(Boolean).join(" ")}
role="table"
>
{children}
</div>
);
}
export function DataTableHeader({ className, children, ...props }: DataTableSectionProps): ReactElement {
return (
<div
{...props}
className={["ui-data-table-header", className].filter(Boolean).join(" ")}
data-ui-region="table-header"
role="rowgroup"
>
{children}
</div>
);
}
export function DataTableBody({ className, children, ...props }: DataTableSectionProps): ReactElement {
return (
<div
{...props}
className={["ui-data-table-body", className].filter(Boolean).join(" ")}
data-ui-region="table-body"
role="rowgroup"
>
{children}
</div>
);
}
export function DataTableEmpty({
title,
description,
illustration,
className,
...props
}: DataTableEmptyProps): ReactElement {
return (
<div
{...props}
className={["ui-data-table-empty", className].filter(Boolean).join(" ")}
role="row"
>
<div className="ui-data-table-empty-cell" role="cell">
{illustration ? <div className="ui-data-table-empty-illustration">{illustration}</div> : null}
<strong className="ui-data-table-empty-title">{title}</strong>
{description ? <span className="ui-data-table-empty-description">{description}</span> : null}
</div>
</div>
);
}
export function DataTableFooter({
pageSize,
rangeLabel,
paginationVisible,
className,
...props
}: DataTableFooterProps): ReactElement | null {
if (!paginationVisible) {
return null;
}
return (
<div
{...props}
aria-label="Seitennavigation"
className={["ui-data-table-footer", className].filter(Boolean).join(" ")}
role="navigation"
>
<span className="ui-data-table-page-size">{pageSize} pro Seite</span>
<span className="ui-data-table-range" aria-live="polite">{rangeLabel}</span>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject
} from "react";
import { getConnectedFocusTarget, restoreFocus } from "./focus";
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
const focusableSelector = "button:not([disabled]), summary, [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
let activeDialogCount = 0;
let blockedShells: Map<HTMLElement, { inert: boolean; ariaHidden: string | null }> | null = null;
export type DialogSize = "default" | "account" | "update" | "wide";
export interface DialogProps {
open: boolean;
title: string;
description?: ReactNode;
size?: DialogSize;
danger?: boolean;
initialFocusRef?: RefObject<HTMLElement>;
restoreFocusTarget?: HTMLElement | null;
restoreFocusFallback?: () => HTMLElement | null;
onClose: () => void;
children: ReactNode;
actions: ReactNode;
closable?: boolean;
showCloseButton?: boolean;
className?: string;
backdropClassName?: string;
headerClassName?: string;
bodyClassName?: string;
actionsClassName?: string;
}
export type DialogKeyboardAction = { type: "focus"; index: number } | { type: "close" };
export function getDialogFocusTarget(shiftKey: boolean, currentIndex: number, itemCount: number): number | null {
if (itemCount <= 0) {
return null;
}
if (currentIndex < 0) {
return shiftKey ? itemCount - 1 : 0;
}
if (shiftKey && currentIndex === 0) {
return itemCount - 1;
}
if (!shiftKey && currentIndex === itemCount - 1) {
return 0;
}
return null;
}
export function getDialogInitialFocusTarget(
dialog: HTMLElement | null,
explicitTarget: HTMLElement | null,
activeTarget: HTMLElement | null = null
): HTMLElement | null {
if (explicitTarget) {
return explicitTarget;
}
if (activeTarget && dialog?.contains(activeTarget)) {
return activeTarget;
}
return dialog?.querySelector<HTMLElement>("[autofocus]") ?? dialog;
}
export function getDialogRestoreFocusTarget(
dialog: HTMLElement | null,
activeTarget: HTMLElement | null
): HTMLElement | null {
if (!activeTarget || dialog?.contains(activeTarget)) {
return null;
}
return activeTarget;
}
export function getConnectedDialogRestoreTarget(
previousTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null
): HTMLElement | null {
return getConnectedFocusTarget(previousTarget, fallbackTarget);
}
export function getDialogKeyboardAction(
key: string,
shiftKey: boolean,
currentIndex: number,
itemCount: number,
closable: boolean
): DialogKeyboardAction | null {
if (key === "Escape") {
return closable ? { type: "close" } : null;
}
if (key !== "Tab" || itemCount <= 0) {
return null;
}
const target = getDialogFocusTarget(shiftKey, currentIndex, itemCount);
return target === null ? null : { type: "focus", index: target };
}
function blockShell(): () => void {
if (activeDialogCount === 0) {
blockedShells = new Map();
document.querySelectorAll<HTMLElement>(".md-shell").forEach((shell) => {
blockedShells?.set(shell, {
inert: shell.hasAttribute("inert"),
ariaHidden: shell.getAttribute("aria-hidden")
});
shell.setAttribute("inert", "");
shell.setAttribute("aria-hidden", "true");
});
}
activeDialogCount += 1;
let released = false;
return () => {
if (released) {
return;
}
released = true;
activeDialogCount = Math.max(0, activeDialogCount - 1);
if (activeDialogCount !== 0 || !blockedShells) {
return;
}
for (const [shell, state] of blockedShells) {
if (!state.inert) {
shell.removeAttribute("inert");
}
if (state.ariaHidden === null) {
shell.removeAttribute("aria-hidden");
} else {
shell.setAttribute("aria-hidden", state.ariaHidden);
}
}
blockedShells = null;
};
}
export function Dialog({
open,
title,
description,
size = "default",
danger = false,
initialFocusRef,
restoreFocusTarget,
restoreFocusFallback,
onClose,
children,
actions,
closable = true,
showCloseButton = false,
className = "",
backdropClassName = "",
headerClassName = "",
bodyClassName = "",
actionsClassName = ""
}: DialogProps): ReactElement | null {
const dialogRef = useRef<HTMLDivElement>(null);
const backdropAttachedRef = useRef(false);
const previousFocusRef = useRef<HTMLElement | null>(null);
const restoreFocusFallbackRef = useRef<() => HTMLElement | null>(() => null);
const titleId = useId();
const descriptionId = useId();
restoreFocusFallbackRef.current = restoreFocusFallback ?? (() => null);
if (open && !previousFocusRef.current && restoreFocusTarget) {
previousFocusRef.current = getDialogRestoreFocusTarget(dialogRef.current, restoreFocusTarget);
}
const captureBackdropRef = useCallback((node: HTMLDivElement | null): void => {
if (node && !backdropAttachedRef.current && !previousFocusRef.current) {
const activeTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
previousFocusRef.current = getDialogRestoreFocusTarget(dialogRef.current, activeTarget);
}
backdropAttachedRef.current = Boolean(node);
}, []);
useImmediateEffect(() => {
if (!open) {
return;
}
const releaseShell = blockShell();
const activeTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusTarget = getDialogInitialFocusTarget(dialogRef.current, initialFocusRef?.current ?? null, activeTarget);
focusTarget?.focus();
return () => {
releaseShell();
const previousFocus = previousFocusRef.current;
const fallbackFocus = restoreFocusFallbackRef.current();
previousFocusRef.current = null;
restoreFocus(previousFocus, fallbackFocus);
};
}, [initialFocusRef, open]);
if (!open) {
return null;
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
const currentIndex = focusable.findIndex((element) => element === document.activeElement);
const action = getDialogKeyboardAction(event.key, event.shiftKey, currentIndex, focusable.length, closable);
if (!action) {
if (event.key === "Tab" && focusable.length === 0) {
event.preventDefault();
}
return;
}
event.preventDefault();
event.stopPropagation();
if (action.type === "close") {
onClose();
return;
}
focusable[action.index]?.focus();
};
const dialogClasses = [
"modal-card",
"md-dialog",
`md-dialog-size-${size}`,
danger ? "is-danger" : "",
className
].filter(Boolean).join(" ");
return (
<div
className={["modal-backdrop", "md-dialog-backdrop", backdropClassName].filter(Boolean).join(" ")}
onClick={() => {
if (closable) {
onClose();
}
}}
ref={captureBackdropRef}
>
<div
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
aria-modal="true"
className={dialogClasses}
onClick={(event) => event.stopPropagation()}
onKeyDown={onKeyDown}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<div className={["md-dialog-header", headerClassName].filter(Boolean).join(" ")}>
<h2 id={titleId}>{title}</h2>
{showCloseButton && closable ? (
<button aria-label="Schließen" className="md-dialog-close" onClick={onClose} type="button">×</button>
) : null}
</div>
<div className={["md-dialog-body", bodyClassName].filter(Boolean).join(" ")}>
{description ? <p className="md-dialog-description" id={descriptionId}>{description}</p> : null}
{children}
</div>
{actions ? (
<div className={["modal-actions", "md-dialog-actions", actionsClassName].filter(Boolean).join(" ")}>{actions}</div>
) : null}
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import type { ReactElement, SVGProps } from "react";
export const ICON_NAMES = [
"download",
"collector",
"settings",
"history",
"statistics",
"add",
"search",
"play",
"pause",
"stop",
"arrow-up",
"arrow-down",
"refresh",
"check",
"edit",
"trash",
"filter",
"folder",
"info",
"more",
"chevron-left",
"chevron-right",
"chevron-down",
"close",
"menu",
"help",
"backup",
"update",
"account"
] as const;
export type IconName = (typeof ICON_NAMES)[number];
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
label?: string;
size?: number;
}
function IconDrawing({ name }: { name: IconName }): ReactElement {
switch (name) {
case "download":
return <><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></>;
case "collector":
return <><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1" /><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2a5 5 0 0 0 7.1 7.1l1.1-1.1" /></>;
case "settings":
return <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>;
case "history":
return <><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l3 2" /></>;
case "statistics":
return <><path d="M4 20V10" /><path d="M10 20V4" /><path d="M16 20v-7" /><path d="M22 20V7" /></>;
case "add":
return <><path d="M12 5v14" /><path d="M5 12h14" /></>;
case "search":
return <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>;
case "play":
return <path d="m8 5 11 7-11 7Z" />;
case "pause":
return <><path d="M9 5v14" /><path d="M15 5v14" /></>;
case "stop":
return <rect x="6" y="6" width="12" height="12" rx="1" />;
case "arrow-up":
return <><path d="m6 10 6-6 6 6" /><path d="M12 4v16" /></>;
case "arrow-down":
return <><path d="m6 14 6 6 6-6" /><path d="M12 20V4" /></>;
case "refresh":
return <><path d="M20 7v5h-5" /><path d="M4 17v-5h5" /><path d="M6.1 9a7 7 0 0 1 11.5-2L20 9" /><path d="m4 15 2.4 2a7 7 0 0 0 11.5-2" /></>;
case "check":
return <path d="m5 12 4 4L19 6" />;
case "edit":
return <><path d="M4 20h4L19 9l-4-4L4 16v4Z" /><path d="m13 7 4 4" /></>;
case "trash":
return <><path d="M4 7h16" /><path d="M9 7V4h6v3" /><path d="m6 7 1 14h10l1-14" /><path d="M10 11v6" /><path d="M14 11v6" /></>;
case "filter":
return <path d="M4 5h16l-6 7v6l-4 2v-8Z" />;
case "folder":
return <path d="M3 6h7l2 2h9v11H3Z" />;
case "info":
return <><circle cx="12" cy="12" r="9" /><path d="M12 11v6" /><path d="M12 7h.01" /></>;
case "more":
return <><circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="19" cy="12" r="1" fill="currentColor" stroke="none" /></>;
case "chevron-left":
return <path d="m15 18-6-6 6-6" />;
case "chevron-right":
return <path d="m9 18 6-6-6-6" />;
case "chevron-down":
return <path d="m6 9 6 6 6-6" />;
case "close":
return <><path d="m6 6 12 12" /><path d="M18 6 6 18" /></>;
case "menu":
return <><path d="M4 6h16" /><path d="M4 12h16" /><path d="M4 18h16" /></>;
case "help":
return <><circle cx="12" cy="12" r="9" /><path d="M9.8 9a2.4 2.4 0 1 1 3.7 2c-1 .6-1.5 1.1-1.5 2" /><path d="M12 17h.01" /></>;
case "backup":
return <><path d="M6 4h10l3 3v13H6Z" /><path d="M9 4v6h6V4" /><path d="M9 20v-6h6v6" /></>;
case "update":
return <><path d="M12 21a9 9 0 1 0-8.5-6" /><path d="M3 15v6h6" /><path d="M12 7v5l3 2" /></>;
case "account":
return <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>;
}
}
export function Icon({ name, label, size = 18, className, ...props }: IconProps): ReactElement {
const labelled = typeof label === "string" && label.trim().length > 0;
return (
<svg
{...props}
aria-hidden={labelled ? undefined : true}
aria-label={labelled ? label : undefined}
className={["ui-icon", className].filter(Boolean).join(" ")}
fill="none"
focusable="false"
height={size}
role={labelled ? "img" : undefined}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
viewBox="0 0 24 24"
width={size}
>
<IconDrawing name={name} />
</svg>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { ReactElement } from "react";
export interface ToastProps {
message: string;
}
export function Toast({ message }: ToastProps): ReactElement | null {
if (!message) {
return null;
}
return <div aria-live="polite" className="toast md-toast" role="status">{message}</div>;
}
+56
View File
@@ -0,0 +1,56 @@
import type { ChangeEventHandler, HTMLAttributes, InputHTMLAttributes, ReactElement } from "react";
import { Icon } from "./Icon";
export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
label: string;
}
export interface ToolbarGroupProps extends HTMLAttributes<HTMLDivElement> {
label: string;
}
export interface ToolbarSearchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "aria-label"> {
label: string;
onChange: ChangeEventHandler<HTMLInputElement>;
}
export function Toolbar({ label, className, children, ...props }: ToolbarProps): ReactElement {
return (
<div
{...props}
aria-label={label}
className={["ui-toolbar", className].filter(Boolean).join(" ")}
role="toolbar"
>
{children}
</div>
);
}
export function ToolbarGroup({ label, className, children, ...props }: ToolbarGroupProps): ReactElement {
return (
<div
{...props}
aria-label={label}
className={["ui-toolbar-group", className].filter(Boolean).join(" ")}
role="group"
>
{children}
</div>
);
}
export function ToolbarSearch({ label, className, placeholder, ...props }: ToolbarSearchProps): ReactElement {
return (
<label className="ui-toolbar-search">
<Icon className="ui-toolbar-search-icon" name="search" size={16} />
<input
{...props}
aria-label={label}
className={["ui-input", "ui-toolbar-search-input", className].filter(Boolean).join(" ")}
placeholder={placeholder ?? label}
type="search"
/>
</label>
);
}
+22
View File
@@ -0,0 +1,22 @@
export type FocusRestoreScheduler = (callback: () => void) => void;
export function getConnectedFocusTarget(
preferredTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null = null
): HTMLElement | null {
if (preferredTarget?.isConnected) {
return preferredTarget;
}
return fallbackTarget?.isConnected ? fallbackTarget : null;
}
export function restoreFocus(
preferredTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null = null,
schedule: FocusRestoreScheduler = queueMicrotask
): void {
schedule(() => {
getConnectedFocusTarget(preferredTarget, fallbackTarget)?.focus();
});
}
+79
View File
@@ -0,0 +1,79 @@
export type UiTheme = "dark" | "light";
export const UI_THEME_VARIABLES = [
"--ui-canvas",
"--ui-surface",
"--ui-input",
"--ui-table-header",
"--ui-active",
"--ui-hover",
"--ui-tooltip",
"--ui-border",
"--ui-text",
"--ui-text-secondary",
"--ui-text-muted",
"--ui-primary",
"--ui-primary-hover",
"--ui-accent",
"--ui-warning",
"--ui-danger",
"--ui-modal-secondary",
"--ui-overlay"
] as const;
export type UiThemeVariable = (typeof UI_THEME_VARIABLES)[number];
export const UI_FOCUS_RING_VARIABLE: UiThemeVariable = "--ui-accent";
type UiThemeVariables = Readonly<Record<UiThemeVariable, string>>;
const darkThemeVariables: UiThemeVariables = Object.freeze({
"--ui-canvas": "#0F0F0F",
"--ui-surface": "#232323",
"--ui-input": "#2B2B2B",
"--ui-table-header": "#313131",
"--ui-active": "#333436",
"--ui-hover": "#373535",
"--ui-tooltip": "#4F4D4D",
"--ui-border": "#3D3D3D",
"--ui-text": "#FFFFFF",
"--ui-text-secondary": "#EAEDF3",
"--ui-text-muted": "#919191",
"--ui-primary": "#BAD0FC",
"--ui-primary-hover": "#8AA5DC",
"--ui-accent": "#3886FF",
"--ui-warning": "#F1C786",
"--ui-danger": "#F06464",
"--ui-modal-secondary": "#35383D",
"--ui-overlay": "rgba(0, 0, 0, 0.60)"
});
const lightThemeVariables: UiThemeVariables = Object.freeze({
"--ui-canvas": "#F3F4F6",
"--ui-surface": "#FFFFFF",
"--ui-input": "#F7F8FA",
"--ui-table-header": "#E7E9ED",
"--ui-active": "#DEE6F5",
"--ui-hover": "#E8ECF3",
"--ui-tooltip": "#35383D",
"--ui-border": "#D0D4DB",
"--ui-text": "#181A1F",
"--ui-text-secondary": "#343842",
"--ui-text-muted": "#667085",
"--ui-primary": "#A9C2F3",
"--ui-primary-hover": "#8AA5DC",
"--ui-accent": "#256FDB",
"--ui-warning": "#E8B85D",
"--ui-danger": "#D94747",
"--ui-modal-secondary": "#E7E9ED",
"--ui-overlay": "rgba(0, 0, 0, 0.45)"
});
const themes: Readonly<Record<UiTheme, UiThemeVariables>> = Object.freeze({
dark: darkThemeVariables,
light: lightThemeVariables
});
export function getThemeVariables(theme: UiTheme): Readonly<Record<string, string>> {
return themes[theme];
}
@@ -0,0 +1,208 @@
import type { ChangeEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import type { CollectorViewModel } from "./collector-model";
import "./collector.css";
export interface CollectorViewActions {
onTabSelect: (tabId: string) => void;
onTabAdd: () => void;
onTabRemove: (tabId: string) => void;
onOpenInput: () => void;
onImportDlc: () => void;
onImportFile: () => void;
onExportQueue: () => void;
onSubmit: () => void;
onQueryChange: (value: string) => void;
onSelectionChange: (rowId: string) => void;
onRemoveSelected: () => void;
}
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
export interface CollectorViewProps {
model: CollectorViewModel;
actions: CollectorViewActions;
region?: CollectorViewRegion;
}
export interface CollectorInputDialogProps {
open: boolean;
tabName: string;
value: string;
onChange: (value: string) => void;
onClose: () => void;
onCommit: () => void;
}
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
return (
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
<div className="collector-sidebar-heading">
<strong>Sammlungen</strong>
<span>{model.tabs.length}</span>
</div>
<div className="collector-sidebar-list">
{model.tabs.map((tab) => (
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} key={tab.id}>
<button
aria-current={tab.id === model.activeTabId ? "page" : undefined}
className="collector-sidebar-select"
onClick={() => actions.onTabSelect(tab.id)}
type="button"
>
<span>{tab.name}</span>
<span className="collector-sidebar-count">{tab.linkCount}</span>
</button>
{model.tabs.length > 1 ? (
<button
aria-label={`${tab.name} entfernen`}
className="collector-sidebar-remove"
onClick={() => actions.onTabRemove(tab.id)}
type="button"
>×</button>
) : null}
</div>
))}
</div>
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
</div>
);
}
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
return (
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
<ToolbarGroup label="Links erfassen">
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
</ToolbarGroup>
<ToolbarGroup label="Sammlung verarbeiten">
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
</ToolbarGroup>
<ToolbarSearch
label="Links durchsuchen"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Links durchsuchen"
value={model.query}
/>
</Toolbar>
);
}
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
const selected = new Set(model.selectedIds);
return (
<section className="collector-content" aria-label="Gesammelte Links">
<DataTable className="collector-table" label="Gesammelte Links">
<DataTableHeader className="collector-table-header">
<div className="collector-table-header-row" role="row">
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Sammlung</span>
<span role="columnheader">URL oder Rohzeile</span>
<span role="columnheader">Zeile</span>
<span role="columnheader">Status</span>
</div>
</DataTableHeader>
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
{model.busy ? (
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
) : model.error ? (
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
) : model.empty ? (
<DataTableEmpty
data-visual-region="collector-empty-state"
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
/>
) : (
model.rows.map((row) => (
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
<span className="collector-column-select" role="cell">
<input
aria-label="Link auswählen"
checked={selected.has(row.id)}
onChange={() => actions.onSelectionChange(row.id)}
type="checkbox"
/>
</span>
<span className="collector-row-source" role="cell">{row.tabName}</span>
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
<span className="collector-row-status" role="cell">Lokal</span>
</div>
))
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
if (region === "sidebar") {
return <CollectorSidebar actions={actions} model={model} />;
}
if (region === "toolbar") {
return <CollectorToolbar actions={actions} model={model} />;
}
if (region === "content") {
return <CollectorContent actions={actions} model={model} />;
}
return (
<div className="collector-view">
<CollectorSidebar actions={actions} model={model} />
<div className="collector-view-main">
<CollectorToolbar actions={actions} model={model} />
<CollectorContent actions={actions} model={model} />
</div>
</div>
);
}
export function CollectorInputDialog({
open,
tabName,
value,
onChange,
onClose,
onCommit
}: CollectorInputDialogProps): ReactElement | null {
return (
<Dialog
actions={(
<>
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
</>
)}
description={`Links für ${tabName} lokal erfassen.`}
onClose={onClose}
open={open}
size="wide"
title="Links hinzufügen"
>
<label className="collector-input-label">
<span>Links</span>
<textarea
aria-label="Links"
autoFocus
className="collector-input"
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
placeholder="Eine URL oder Rohzeile pro Zeile"
rows={12}
value={value}
/>
</label>
</Dialog>
);
}
@@ -0,0 +1,88 @@
export interface CollectorSourceTab {
id: string;
name: string;
text: string;
}
export interface CollectorTabSummary {
id: string;
name: string;
linkCount: number;
}
export interface CollectorRow {
id: string;
tabId: string;
tabName: string;
originalLineIndex: number;
lineNumber: number;
value: string;
linkCount: number;
}
export interface CollectorViewModel {
tabs: CollectorTabSummary[];
activeTabId: string;
rows: CollectorRow[];
busy: boolean;
query: string;
selectedIds: string[];
empty: boolean;
error: string;
}
function nonEmptyLines(tab: CollectorSourceTab): Array<{ originalLineIndex: number; value: string }> {
return tab.text
.split(/\r?\n/)
.map((value, originalLineIndex) => ({ originalLineIndex, value: value.trim() }))
.filter((line) => line.value.length > 0);
}
export function buildCollectorRows(
tabs: CollectorSourceTab[],
activeTabId: string = tabs[0]?.id ?? "",
query = ""
): CollectorRow[] {
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
if (!activeTab) {
return [];
}
const lines = nonEmptyLines(activeTab);
const normalizedQuery = query.trim().toLocaleLowerCase("de");
return lines
.filter((line) => !normalizedQuery || line.value.toLocaleLowerCase("de").includes(normalizedQuery))
.map((line) => ({
id: `${activeTab.id}:${line.originalLineIndex}`,
tabId: activeTab.id,
tabName: activeTab.name,
originalLineIndex: line.originalLineIndex,
lineNumber: line.originalLineIndex + 1,
value: line.value,
linkCount: lines.length
}));
}
export function buildCollectorViewModel(
tabs: CollectorSourceTab[],
activeTabId: string,
query: string,
busy: boolean,
selectedIds: string[],
error = ""
): CollectorViewModel {
const rows = buildCollectorRows(tabs, activeTabId, query);
return {
tabs: tabs.map((tab) => ({
id: tab.id,
name: tab.name,
linkCount: nonEmptyLines(tab).length
})),
activeTabId,
rows,
busy,
query,
selectedIds,
empty: rows.length === 0,
error
};
}
+309
View File
@@ -0,0 +1,309 @@
.collector-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.collector-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
}
.collector-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
}
.collector-sidebar-heading {
align-items: center;
color: var(--ui-text-secondary);
display: flex;
font-size: 13px;
justify-content: space-between;
min-height: 28px;
}
.collector-sidebar-heading span,
.collector-sidebar-count {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-sidebar-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.collector-sidebar-item {
align-items: stretch;
border: 1px solid transparent;
border-radius: 6px;
display: flex;
min-height: 36px;
}
.collector-sidebar-item:hover {
background: var(--ui-hover);
}
.collector-sidebar-item.is-active {
background: var(--ui-active);
border-color: var(--ui-border);
}
.collector-sidebar-select,
.collector-sidebar-remove,
.collector-sidebar-add {
background: transparent;
border: 0;
color: var(--ui-text-secondary);
font: inherit;
}
.collector-sidebar-select {
align-items: center;
display: flex;
flex: 1;
gap: 8px;
justify-content: space-between;
min-width: 0;
padding: 0 8px;
text-align: left;
}
.collector-sidebar-select span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-sidebar-remove {
border-left: 1px solid var(--ui-border);
min-width: 32px;
}
.collector-sidebar-add {
border: 1px solid var(--ui-border);
border-radius: 6px;
min-height: 36px;
padding: 0 10px;
text-align: left;
}
.collector-sidebar-add:hover,
.collector-sidebar-remove:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-toolbar {
min-width: 0;
width: 100%;
}
.collector-action {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text-secondary);
font: inherit;
height: 36px;
padding: 0 12px;
white-space: nowrap;
}
.collector-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-action-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-action-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
color: #181A1F;
}
.collector-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.collector-action:disabled {
cursor: default;
opacity: 0.45;
}
.collector-content {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.collector-table {
height: 100%;
}
.collector-table-header-row,
.collector-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
min-width: 760px;
}
.collector-table-header {
height: 41px;
}
.collector-table-header-row {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.collector-table-header-row > span,
.collector-row > span {
min-width: 0;
padding: 0 12px;
}
.collector-table-body {
overflow: auto;
}
.collector-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.collector-row:hover {
background: var(--ui-hover);
}
.collector-row.is-selected {
background: var(--ui-active);
}
.collector-column-select {
display: grid;
place-items: center;
}
.collector-row-source {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-row-value {
color: var(--ui-text);
overflow: hidden;
text-overflow: ellipsis;
user-select: text;
white-space: nowrap;
}
.collector-row-line,
.collector-row-status {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.collector-input-label {
display: grid;
gap: 8px;
}
.collector-input-label > span {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
}
.collector-input {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text);
font: inherit;
line-height: 1.5;
min-height: 240px;
padding: 12px;
resize: vertical;
user-select: text;
width: 100%;
}
.collector-dialog-primary,
.collector-dialog-secondary {
border: 1px solid var(--ui-border);
border-radius: 6px;
font: inherit;
height: 36px;
padding: 0 14px;
}
.collector-dialog-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-dialog-secondary {
background: var(--ui-modal-secondary);
color: var(--ui-text-secondary);
}
@media (max-width: 1366px) {
.collector-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.collector-sidebar {
overflow: hidden;
}
.collector-action {
padding: 0 9px;
}
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
min-width: 660px;
}
}
@media (max-width: 1120px) {
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
min-width: 610px;
}
}
@@ -0,0 +1,375 @@
import { memo, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
import type { DownloadItem } from "../../../shared/types";
import {
compactProviderLabels,
extractHoster,
formatAudioStripSummary,
formatDateTime,
formatSpeedMbps,
humanSize,
providerLabels
} from "../../download-format";
import type { DownloadPackageRow } from "./downloads-model";
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" },
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
account: { label: "Service", width: "132px" },
prio: { label: "Priorität", width: "70px" },
status: { label: "Status", width: "160px" },
speed: { label: "Geschwindigkeit", width: "90px" },
added: { label: "Hinzugefügt am", width: "155px" }
};
export interface DownloadsTableActions {
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void;
onSelectionMouseEnter: (id: string) => void;
onTogglePackage: (packageId: string) => void;
onTogglePackageCollapse: (packageId: string) => void;
onStartPackageRename: (packageId: string, packageName: string) => void;
onPackageRenameChange: (name: string) => void;
onCommitPackageRename: (packageId: string, value: string) => void;
onCancelPackageRename: (packageId: string) => void;
onCancelPackage: (packageId: string) => void;
onMovePackageUp: (packageId: string) => void;
onMovePackageDown: (packageId: string) => void;
onRemoveItem: (itemId: string) => void;
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
onColumnDragStart: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragOver: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragLeave: () => void;
onColumnDrop: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragEnd: () => void;
onColumnContextMenu: (column: string, x: number, y: number) => void;
onSortColumn: (column: DownloadSortColumn) => void;
}
function displayedStatus(item: DownloadItem, sessionRunning: boolean): string {
const value = item.fullStatus.trim();
if (value === "Wartet") return "";
if (sessionRunning) return value;
if (item.status !== "queued" && item.status !== "reconnect_wait") return value;
if (value === "Paket gestoppt") return value;
if (/^Entpacken\b/i.test(value) || /^Entpackt\b/i.test(value) || /^Entpack-Fehler\b/i.test(value) || /^Fertig\b/i.test(value)) return value;
return "";
}
function progress(value: number): number {
return Math.max(0, Math.min(100, Math.round(value || 0)));
}
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
const displayStatus = displayedStatus(item, sessionRunning);
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
const error = item.lastError.trim();
const statusTitle = displayStatus
? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}`
: error;
if (column === "name") {
return <span className="downloads-cell downloads-name-cell downloads-copyable" title={item.fileName}><span className={`downloads-link-state ${item.onlineStatus ?? "unknown"}`} />{item.fileName}</span>;
}
if (column === "size") {
const total = item.totalBytes || item.downloadedBytes || 0;
const value = total > 0 ? progress((item.downloadedBytes / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(item.downloadedBytes)} / {humanSize(total)}</b></span> : null}</span>;
}
if (column === "progress") {
const value = progress(item.progressPercent);
return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{value}%</b></span></span>;
}
if (column === "hoster") {
const hoster = extractHoster(item.url);
return <span className="downloads-cell" title={hoster}>{hoster}</span>;
}
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
if (column === "prio") return <span className="downloads-cell" />;
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
return null;
}
export interface ItemRowProps {
item: DownloadItem;
selected: boolean;
sessionRunning?: boolean;
columnOrder: readonly string[];
gridTemplate: string;
actions: DownloadsTableActions;
}
export function ItemRowContent({ item, selected, sessionRunning = true, columnOrder, gridTemplate, actions }: ItemRowProps): ReactElement {
return (
<div
className={`downloads-item-row${selected ? " is-selected" : ""}`}
data-download-row-id={item.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
onClick={(event) => {
event.stopPropagation();
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
}}
onMouseDown={(event) => {
event.stopPropagation();
actions.onSelectionMouseDown(item.id, event);
}}
onMouseEnter={() => actions.onSelectionMouseEnter(item.id)}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId);
}}
>
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => actions.onToggleSelection(item.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${item.fileName} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} type="button"></button></span>
</div>
);
}
export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps): boolean {
const a = previous.item;
const b = next.item;
return a.id === b.id
&& a.updatedAt === b.updatedAt
&& a.status === b.status
&& a.fileName === b.fileName
&& a.url === b.url
&& a.provider === b.provider
&& a.providerLabel === b.providerLabel
&& a.providerAccountId === b.providerAccountId
&& a.providerAccountLabel === b.providerAccountLabel
&& a.fullStatus === b.fullStatus
&& a.lastError === b.lastError
&& a.onlineStatus === b.onlineStatus
&& a.progressPercent === b.progressPercent
&& a.speedBps === b.speedBps
&& a.downloadedBytes === b.downloadedBytes
&& a.totalBytes === b.totalBytes
&& a.retries === b.retries
&& a.createdAt === b.createdAt
&& previous.selected === next.selected
&& previous.sessionRunning === next.sessionRunning
&& previous.columnOrder === next.columnOrder
&& previous.gridTemplate === next.gridTemplate
&& previous.actions === next.actions;
}
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
let done = 0;
let failed = 0;
let cancelled = 0;
let extracted = 0;
let extracting = false;
let activeProgress = 0;
let extractingProgress = 0;
for (const item of row.items) {
if (item.status === "completed") done += 1;
else if (item.status === "failed") failed += 1;
else if (item.status === "cancelled") cancelled += 1;
const fullStatus = item.fullStatus || "";
if (fullStatus.startsWith("Entpackt")) {
extracted += 1;
} else if (fullStatus.startsWith("Entpacken")) {
extracting = true;
const match = fullStatus.match(/^Entpacken\s+(\d+)%/);
if (match) extractingProgress += Number(match[1]) / 100;
}
if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
activeProgress += (item.progressPercent || 0) / 100;
}
}
const total = Math.max(1, row.items.length);
const allDownloaded = done + failed + cancelled >= total;
const allExtracted = extracted >= total;
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
return { done, failed, cancelled, total, value };
}
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
const entry = row.package;
const stats = packageProgress(row);
if (column === "name") {
return (
<span className="downloads-cell downloads-name-cell">
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
<input aria-label={`${entry.name} aktivieren`} checked={entry.enabled} onChange={() => actions.onTogglePackage(entry.id)} onClick={(event) => event.stopPropagation()} type="checkbox" />
{editing
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
finishRename(editingName);
event.currentTarget.blur();
} else if (event.key === "Escape") {
event.preventDefault();
actions.onCancelPackageRename(entry.id);
}
}} />
: <strong className="downloads-copyable" onDoubleClick={(event) => { event.stopPropagation(); actions.onStartPackageRename(entry.id, entry.name); }} title={entry.name}>{entry.name}</strong>}
</span>
);
}
if (column === "size") {
const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
const value = total > 0 ? progress((downloaded / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(downloaded)} / {humanSize(total)}</b></span> : null}</span>;
}
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
if (column === "hoster") {
const value = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
return <span className="downloads-cell" title={value}>{value}</span>;
}
if (column === "account") {
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
return <span className="downloads-cell" title={value}>{value}</span>;
}
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
if (column === "status") {
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
}
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
return null;
}
export interface PackageCardProps {
row: DownloadPackageRow;
selectedIds: Set<string>;
selectedVersion: number;
editing: boolean;
editingName: string;
packageSpeedBps: number;
sessionRunning?: boolean;
columnOrder: readonly string[];
gridTemplate: string;
actions: DownloadsTableActions;
draggable?: boolean;
onDragStart?: (packageId: string) => void;
onDrop?: (packageId: string) => void;
onDragEnd?: () => void;
}
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions, draggable = true, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
const entry = row.package;
let renameFinished = false;
const finishRename = (value: string): void => {
if (renameFinished) return;
renameFinished = true;
actions.onCommitPackageRename(entry.id, value);
};
return (
<article
className={`package-card downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
data-download-package-id={entry.id}
draggable={draggable}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id);
}}
onDragStart={(event) => { event.stopPropagation(); onDragStart?.(entry.id); }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop?.(entry.id); }}
onDragEnd={(event) => { event.stopPropagation(); onDragEnd?.(); }}
>
<div
className="downloads-package-row"
data-download-row-id={entry.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey);
return;
}
if (target.closest("button, input, select")) return;
actions.onTogglePackageCollapse(entry.id);
}}
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
>
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => actions.onToggleSelection(entry.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button"></button></span>
</div>
{!row.collapsed && row.items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
</article>
);
}
export function arePackageCardPropsEqual(previous: PackageCardProps, next: PackageCardProps): boolean {
const a = previous.row.package;
const b = next.row.package;
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions || previous.draggable !== next.draggable || previous.onDragStart !== next.onDragStart || previous.onDrop !== next.onDrop || previous.onDragEnd !== next.onDragEnd) return false;
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
for (const itemId of b.itemIds) {
if (previous.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) return false;
}
}
if (previous.row.items.length !== next.row.items.length) return false;
for (let index = 0; index < previous.row.items.length; index += 1) {
const oldItem = previous.row.items[index];
const newItem = next.row.items[index];
if (!oldItem || !newItem || !areItemRowPropsEqual({ actions: previous.actions, columnOrder: previous.columnOrder, gridTemplate: previous.gridTemplate, item: oldItem, selected: previous.selectedIds.has(oldItem.id), sessionRunning: previous.sessionRunning }, { actions: next.actions, columnOrder: next.columnOrder, gridTemplate: next.gridTemplate, item: newItem, selected: next.selectedIds.has(newItem.id), sessionRunning: next.sessionRunning })) return false;
}
return true;
}
export const PackageCard = memo(PackageCardContent, arePackageCardPropsEqual);
export interface DownloadsTableHeaderProps {
actions: DownloadsTableActions;
columnOrder: readonly string[];
gridTemplate: string;
sortColumn: DownloadSortColumn;
sortDirection: "asc" | "desc";
selectedCount: number;
visibleIds: string[];
}
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
return (
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}>
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
{columnOrder.map((column) => {
const definition = downloadColumnDefinitions[column];
if (!definition) return null;
return (
<div
className="downloads-column-header"
draggable
key={column}
onContextMenu={(event) => { event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
onDragEnd={actions.onColumnDragEnd}
onDragLeave={actions.onColumnDragLeave}
onDragOver={(event) => actions.onColumnDragOver(column, event)}
onDragStart={(event) => actions.onColumnDragStart(column, event)}
onDrop={(event) => actions.onColumnDrop(column, event)}
role="columnheader"
>
{definition.sortable
? <button onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
: definition.label}
</div>
);
})}
<span className="downloads-action-cell" role="columnheader">Aktion</span>
</div>
);
}
@@ -0,0 +1,197 @@
import type { ReactElement } from "react";
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
import {
DownloadsTableHeader,
ItemRow,
PackageCard,
type DownloadSortColumn,
type DownloadsTableActions
} from "./DownloadsTable";
import "./downloads.css";
export interface DownloadsStatusModel {
packages: number;
links: number;
session: string;
total: string;
hosters: number;
speed: string;
eta: string;
}
export interface DownloadsViewModel extends DownloadsViewModelCore {
running: boolean;
paused: boolean;
canStart: boolean;
canPause: boolean;
canStop: boolean;
actionBusy: boolean;
reconnectSeconds: number;
reconnectReason: string;
clipboardWatcher: boolean;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
editingName: string;
columnOrder: readonly string[];
gridTemplate: string;
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
status: DownloadsStatusModel;
}
export interface DownloadsViewActions extends DownloadsTableActions {
onDisplayModeChange: (mode: DownloadDisplayMode) => void;
onFilterChange: (filter: DownloadSidebarFilter) => void;
onProviderFilterChange: (provider: string) => void;
onQueryChange: (query: string) => void;
onAddLinks: () => void;
onStartDownloads: () => void;
onPauseDownloads: () => void;
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
onMoveSelectionDown: () => void;
onRenameSelection: () => void;
onRemoveSelection: () => void;
onToggleClipboardWatcher: () => void;
onClearAll: () => void;
onToggleAllPackages: () => void;
onShowAllPackages: () => void;
onPackageDragStart: (packageId: string) => void;
onPackageDrop: (packageId: string) => void;
onPackageDragEnd: () => void;
}
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
{ id: "all", label: "Alle" },
{ id: "active", label: "Aktiv" },
{ id: "queued", label: "Wartend" },
{ id: "paused", label: "Pausiert" },
{ id: "completed", label: "Fertig" },
{ id: "failed", label: "Fehler" }
];
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
<div className="downloads-mode-switch" role="group" aria-label="Downloadansicht">
<button className={model.displayMode === "packages" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("packages")} type="button">Pakete</button>
<button className={model.displayMode === "files" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("files")} type="button">Dateien</button>
</div>
<nav aria-label="Downloadfilter">
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
</nav>
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
<div className="downloads-sidebar-actions">
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
<label><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
</div>
</aside>
);
}
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
const entries = [
["Pakete", String(model.status.packages)],
["Links", String(model.status.links)],
["Sitzung", model.status.session],
["Gesamt", model.status.total],
["Hoster", String(model.status.hosters)],
["Geschwindigkeit", model.status.speed],
["ETA", model.status.eta]
];
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</section>;
}
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
<span className="downloads-toolbar-divider" />
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
<span className="downloads-toolbar-divider" />
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
</div>
);
}
function tableState(model: DownloadsViewModel): ReactElement | null {
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
return null;
}
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
return model.packageRows.map((row: DownloadPackageRow) => (
<PackageCard
actions={actions}
columnOrder={model.columnOrder}
editing={model.editingPackageId === row.package.id}
editingName={model.editingName}
gridTemplate={model.gridTemplate}
key={row.package.id}
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
onDragEnd={actions.onPackageDragEnd}
onDragStart={actions.onPackageDragStart}
onDrop={actions.onPackageDrop}
row={row}
selectedIds={model.selectedIds}
selectedVersion={model.actionableSelectedIds.length}
sessionRunning={model.running}
/>
));
}
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<main className="downloads-content">
<div className="downloads-table" role="table" aria-label="Downloads">
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
{tableState(model)}
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
</div>
</div>
</main>
);
}
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<footer className="downloads-footer" data-visual-region="downloads-pagination">
<span>{model.paginationLabel}</span>
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
</footer>
);
}
export function DownloadsView({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<div className="downloads-view">
<div className="downloads-side-column"><DownloadsSidebar actions={actions} model={model} /><DownloadsSidebarStatus model={model} /></div>
<div className="downloads-main-column"><DownloadsToolbar actions={actions} model={model} /><DownloadsContent actions={actions} model={model} /><DownloadsFooter actions={actions} model={model} /></div>
</div>
);
}
@@ -0,0 +1,186 @@
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
export type DownloadDisplayMode = "packages" | "files";
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
export interface DownloadsModelInput {
packageOrder: string[];
packages: Record<string, PackageEntry>;
items: Record<string, DownloadItem>;
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
query: string;
collapsedPackageIds: Iterable<string>;
selectedIds: Iterable<string>;
hideExtractedItems: boolean;
showAllPackages: boolean;
renderLimit: number;
}
export interface DownloadFilterCounts {
all: number;
active: number;
queued: number;
paused: number;
completed: number;
failed: number;
}
export interface DownloadPackageRow {
package: PackageEntry;
items: DownloadItem[];
collapsed: boolean;
}
export interface DownloadsViewModelCore {
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
providerOptions: Array<{ id: string; label: string }>;
query: string;
counts: DownloadFilterCounts;
packageRows: DownloadPackageRow[];
fileRows: DownloadItem[];
visibleItemIds: string[];
visibleRowIds: string[];
actionableSelectedIds: string[];
actionableSelectedPackageIds: string[];
selectedIds: Set<string>;
mainRowCount: number;
totalMainRowCount: number;
paginationLabel: string;
limited: boolean;
empty: boolean;
filteredEmpty: boolean;
}
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
if (activeStatuses.has(status)) return "active";
if (queuedStatuses.has(status)) return "queued";
if (status === "paused" || status === "completed" || status === "failed") return status;
return "all";
}
export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): DownloadFilterCounts {
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
for (const item of items) {
counts.all += 1;
const category = classifyDownloadStatus(item.status);
if (category !== "all") counts[category] += 1;
}
return counts;
}
function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadStatus(item.status) === filter;
}
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
return providerFilter === "all" || item.provider === providerFilter;
}
function isActivePackage(row: DownloadPackageRow): boolean {
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
}
function paginationLabel(visible: number, total: number): string {
if (visible === 0 || total === 0) return "0 von 0";
return `1${visible} von ${total}`;
}
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
const allPackages = input.packageOrder
.map((id) => input.packages[id])
.filter((entry): entry is PackageEntry => Boolean(entry));
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
const counts = buildDownloadSidebarCounts(allItems);
const providerMap = new Map<string, string>();
for (const entry of allItems) {
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
}
const query = input.query.trim().toLocaleLowerCase("de-DE");
const collapsed = new Set(input.collapsedPackageIds);
const selectedIds = new Set(input.selectedIds);
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
const items = entry.itemIds
.map((id) => input.items[id])
.filter((item): item is DownloadItem => Boolean(item))
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
const matchingItems = items.filter((item) => {
const itemMatchesQuery = query === ""
|| matchesQuery(item.fileName, query)
|| matchesQuery(item.targetPath, query)
|| matchesQuery(item.providerLabel, query)
|| matchesQuery(item.providerAccountLabel, query)
|| matchesQuery(item.fullStatus, query)
|| matchesQuery(item.lastError, query);
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
if (matchingItems.length === 0) return [];
const visibleItems = packageMatchesQuery && query !== ""
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
: matchingItems;
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
});
const totalPackageRows = packageRows.length;
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
const activeRows = packageRows.filter(isActivePackage);
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
}
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
const visibleItemIds = (input.displayMode === "files"
? fileRows
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
const visibleRowIds = input.displayMode === "files"
? visibleItemIds
: displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]);
const visibleRowSet = new Set(visibleRowIds);
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
const totalMainRowCount = input.displayMode === "files"
? fileRows.length
: totalPackageRows;
return {
displayMode: input.displayMode,
filter: input.filter,
providerFilter: input.providerFilter,
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query,
counts,
packageRows: displayedPackages,
fileRows,
visibleItemIds,
visibleRowIds,
actionableSelectedIds,
actionableSelectedPackageIds,
selectedIds,
mainRowCount,
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: mainRowCount < totalMainRowCount,
empty: allItems.length === 0,
filteredEmpty: allItems.length > 0 && mainRowCount === 0
};
}
+423
View File
@@ -0,0 +1,423 @@
.downloads-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-height: 0;
height: 100%;
overflow: hidden;
background: var(--ui-canvas);
}
.downloads-side-column {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.downloads-sidebar,
.downloads-sidebar-status,
.downloads-toolbar,
.downloads-content,
.downloads-footer {
user-select: none;
}
.downloads-copyable,
.downloads-search-input,
.downloads-rename-input {
user-select: text;
}
.downloads-sidebar {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
min-height: 0;
padding: 14px 12px;
}
.downloads-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 2px;
border: 1px solid var(--ui-border);
border-radius: 6px;
}
.downloads-mode-switch button,
.downloads-sidebar nav button,
.downloads-sidebar-actions button {
min-height: 36px;
border: 0;
border-radius: 4px;
color: var(--ui-text);
background: transparent;
text-align: left;
}
.downloads-mode-switch button {
text-align: center;
}
.downloads-mode-switch button.is-active,
.downloads-sidebar nav button.is-active {
color: #ffffff;
background: var(--ui-accent);
}
.downloads-sidebar nav {
display: grid;
gap: 3px;
}
.downloads-sidebar nav button {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
}
.downloads-provider-filter,
.downloads-sidebar-search {
display: grid;
gap: 5px;
color: var(--ui-text-muted);
font-size: 12px;
}
.downloads-provider-filter select,
.downloads-sidebar-search input,
.downloads-schedule-controls input,
.downloads-rename-input {
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 5px;
padding: 0 10px;
color: var(--ui-text);
background: var(--ui-input);
}
.downloads-sidebar-actions {
display: grid;
gap: 5px;
margin-top: auto;
}
.downloads-sidebar-actions label {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 8px;
}
.downloads-sidebar-status {
display: grid;
gap: 6px;
padding: 12px;
border-top: 1px solid var(--ui-border);
}
.downloads-sidebar-status div {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--ui-text-muted);
font-size: 12px;
}
.downloads-sidebar-status strong {
color: var(--ui-text);
font-weight: 600;
text-align: right;
}
.downloads-main-column {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.downloads-toolbar {
display: flex;
align-items: center;
gap: 6px;
min-height: 52px;
padding: 8px 10px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.downloads-toolbar button,
.downloads-footer button,
.downloads-action-cell button,
.downloads-collapse-button,
.downloads-column-header button {
min-height: 36px;
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 5px;
padding: 0 10px;
color: var(--ui-text);
background: var(--ui-modal-secondary);
}
.downloads-toolbar button:disabled,
.downloads-footer button:disabled {
opacity: 0.42;
}
.downloads-toolbar-divider {
width: 1px;
align-self: stretch;
margin: 1px 2px;
background: var(--ui-border);
}
.downloads-schedule-controls {
display: flex;
align-items: center;
gap: 5px;
}
.downloads-schedule-controls input {
height: 36px;
}
.downloads-sidebar-search span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.downloads-content {
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
background: var(--ui-canvas);
}
.downloads-table {
width: 100%;
height: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: auto;
}
.downloads-table-header {
display: grid;
align-items: center;
height: 41px;
position: sticky;
top: 0;
z-index: 2;
min-width: max-content;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-muted);
background: var(--ui-table-header);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.downloads-table-body {
min-width: max-content;
overflow: visible;
}
.downloads-package-card {
min-width: max-content;
border: 0;
border-bottom: 1px solid var(--ui-border);
border-radius: 0;
padding: 0;
background: var(--ui-canvas);
}
.downloads-package-card.is-disabled {
opacity: 0.58;
}
.downloads-item-row,
.downloads-package-row {
display: grid;
align-items: center;
height: 48px;
min-width: max-content;
color: var(--ui-text);
background: var(--ui-canvas);
}
.downloads-item-row {
border-top: 1px solid var(--ui-border);
color: var(--ui-text-muted);
}
.downloads-item-row.is-selected,
.downloads-package-card.is-selected > .downloads-package-row {
background: var(--ui-active);
}
.downloads-cell-slot {
min-width: 0;
}
.downloads-cell,
.downloads-name-cell {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
padding: 0 9px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-name-cell strong {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-selection-cell,
.downloads-action-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
.downloads-action-cell button,
.downloads-collapse-button {
width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
}
.downloads-column-header {
min-width: 0;
padding: 0 9px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-column-header button {
width: 100%;
border: 0;
padding: 0;
background: transparent;
text-align: left;
text-transform: inherit;
}
.downloads-link-state {
flex: 0 0 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ui-text-muted);
}
.downloads-link-state.online {
background: var(--ui-primary);
}
.downloads-link-state.offline {
background: var(--ui-danger);
}
.downloads-link-state.checking {
background: var(--ui-warning);
}
.downloads-meter {
position: relative;
display: block;
width: 100%;
height: 24px;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-modal-secondary);
}
.downloads-meter > span {
position: absolute;
inset: 0 auto 0 0;
background: color-mix(in srgb, var(--ui-accent) 58%, transparent);
}
.downloads-meter > b {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 11px;
font-weight: 600;
}
.downloads-empty-state,
.downloads-table-message {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--ui-text-muted);
}
.downloads-empty-state > div,
.downloads-table-message > div {
display: grid;
gap: 4px;
text-align: center;
}
.downloads-empty-state strong,
.downloads-table-message strong {
color: var(--ui-text);
}
.downloads-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 60px;
padding: 0 12px 0 60px;
border-top: 1px solid var(--ui-border);
color: var(--ui-text-muted);
background: var(--ui-surface);
font-size: 12px;
}
@media (max-width: 1366px) {
.downloads-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.downloads-side-column {
overflow: hidden;
}
}
@media (max-width: 1120px) {
.downloads-toolbar,
.downloads-footer {
overflow-x: auto;
}
}
+239
View File
@@ -0,0 +1,239 @@
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableFooter,
DataTableHeader
} from "../../ui/DataTable";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
import "./history.css";
export interface HistoryViewActions {
onFilterChange: (filter: HistoryFilter) => void;
onQueryChange: (value: string) => void;
onToggleSelection: (entryId: string) => void;
onToggleSelectAll: (visibleIds: string[]) => void;
onToggleExpansion: (entryId: string) => void;
onRestore: (entryIds: string[]) => void;
onReveal: (entryId: string) => void;
onRemove: (entryIds: string[]) => void;
onClearSelection: () => void;
onClearHistory: () => void;
onContextMenu: (entryId: string, x: number, y: number) => void;
}
export interface HistoryViewProps {
model: HistoryViewModel;
actions: HistoryViewActions;
}
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
{ id: "all", label: "Alle Einträge" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Letzte 7 Tage" },
{ id: "older", label: "Älter" },
{ id: "completed", label: "Fertig" },
{ id: "deleted", label: "Gelöscht" },
{ id: "failed", label: "Fehlgeschlagen" }
];
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
return (
<div className="history-detail-row" role="row">
<div className="history-detail-cell" role="cell">
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
</div>
</div>
);
}
export function HistorySidebar({ model, actions }: HistoryViewProps): ReactElement {
return (
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
<strong className="history-sidebar-heading">Verlauf</strong>
<div className="history-filter-list">
{filterItems.map((item) => (
<button
aria-current={model.filter === item.id ? "page" : undefined}
className={`history-filter${model.filter === item.id ? " is-active" : ""}`}
key={item.id}
onClick={() => actions.onFilterChange(item.id)}
type="button"
>
<span>{item.label}</span>
<span>{model.counts[item.id]}</span>
</button>
))}
</div>
<button
className="history-sidebar-clear"
disabled={model.totalCount === 0 || model.loading}
onClick={actions.onClearHistory}
type="button"
>Verlauf leeren</button>
</div>
);
}
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
const selectedIds = model.selectedIds;
const selectedSet = new Set(selectedIds);
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
return (
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
<ToolbarGroup label="Einträge">
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
</ToolbarGroup>
<ToolbarSearch
label="Verlauf durchsuchen"
onChange={(event: ChangeEvent<HTMLInputElement>) => actions.onQueryChange(event.target.value)}
placeholder="Name, Pfad, Hoster oder Provider"
value={model.query}
/>
</Toolbar>
);
}
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
const selected = new Set(model.selectedIds);
const expanded = new Set(model.expandedIds);
const visibleIds = model.rows.map((row) => row.id);
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
? "Noch kein Verlauf"
: "Keine passenden Einträge";
return (
<section aria-label="Verlaufstabelle" className="history-content">
<DataTable className="history-table" label="Verlauf">
<DataTableHeader className="history-table-header">
<div className="history-table-header-row" role="row">
<span className="history-column-select" role="columnheader">
<input
aria-label="Alle sichtbaren Einträge auswählen"
checked={allVisibleSelected}
disabled={visibleIds.length === 0}
onChange={() => actions.onToggleSelectAll(visibleIds)}
type="checkbox"
/>
</span>
<span role="columnheader">Paket / Datei</span>
<span role="columnheader">Status</span>
<span role="columnheader">Größe</span>
<span role="columnheader">Hoster</span>
<span role="columnheader">Gestartet</span>
<span role="columnheader">Beendet</span>
<span role="columnheader">Aktion</span>
</div>
</DataTableHeader>
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
{model.loading ? (
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
) : model.error ? (
<DataTableEmpty className="history-table-error" description="Öffne die Ansicht erneut, um es noch einmal zu versuchen." title={model.error} />
) : showEmpty ? (
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
) : (
model.rows.map((row) => {
const isSelected = selected.has(row.id);
const isExpanded = expanded.has(row.id);
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.querySelector<HTMLButtonElement>(".history-row-action button")?.focus({ preventScroll: true });
actions.onContextMenu(row.id, event.clientX, event.clientY);
};
return (
<div className="history-row-group" key={row.id}>
<div
className={`history-row${isSelected ? " is-selected" : ""}`}
data-history-row-id={row.id}
onContextMenu={onContextMenu}
role="row"
>
<span className="history-column-select" role="cell">
<input
aria-label={`${row.name} auswählen`}
checked={isSelected}
onChange={() => actions.onToggleSelection(row.id)}
type="checkbox"
/>
</span>
<span className="history-row-name" role="cell">
<button
aria-expanded={isExpanded}
aria-label={isExpanded ? "Details ausblenden" : "Details anzeigen"}
className="history-expand"
onClick={(event) => {
event.stopPropagation();
actions.onToggleExpansion(row.id);
}}
type="button"
>{isExpanded ? "" : "+"}</button>
<span title={row.name}>{row.name}</span>
</span>
<span role="cell"><span className={`history-status history-status-${row.status}`}>{row.statusLabel}</span></span>
<span className="history-row-size" role="cell" title={row.sizeLabel}>{row.sizeLabel}</span>
<span className="history-row-hoster" role="cell" title={row.hoster}>{row.hoster}</span>
<span className="history-row-time" role="cell">{row.startedLabel}</span>
<span className="history-row-time" role="cell">{row.completedLabel}</span>
<span className="history-row-action" role="cell">
<button
aria-label={`Aktionen für ${row.name}`}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
actions.onContextMenu(row.id, rect.right, rect.bottom);
}}
type="button"
></button>
</span>
</div>
{isExpanded ? <HistoryRowDetails row={row} /> : null}
</div>
);
})
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
const count = model.rows.length;
return (
<DataTableFooter
className="history-pagination"
data-visual-region="history-pagination"
pageSize={count}
paginationVisible
rangeLabel={count === 0 ? "0 von 0" : `1${count} von ${count}`}
/>
);
}
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
return (
<div className="history-workspace-view">
<HistorySidebar actions={actions} model={model} />
<div className="history-view-main">
<HistoryToolbar actions={actions} model={model} />
<HistoryContent actions={actions} model={model} />
<HistoryFooter model={model} />
</div>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
import type { DebridProvider, HistoryEntry } from "../../../shared/types";
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed";
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
export type HistoryViewEntry = Omit<HistoryEntry, "status"> & { status: HistoryViewStatus };
export interface HistoryRow extends HistoryViewEntry {
hoster: string;
providerLabel: string;
startAt: number;
sizeLabel: string;
startedLabel: string;
completedLabel: string;
durationLabel: string;
averageSpeedLabel: string;
statusLabel: string;
}
export interface HistoryFilterCounts {
all: number;
today: number;
week: number;
older: number;
completed: number;
deleted: number;
failed: number;
}
export interface HistoryViewModel {
rows: HistoryRow[];
filter: HistoryFilter;
query: string;
selectedIds: string[];
expandedIds: string[];
counts: HistoryFilterCounts;
loading: boolean;
error: string;
totalCount: number;
}
const providerLabels: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
megadebrid: "Mega-Debrid",
"megadebrid-api": "Mega-Debrid API",
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
linksnappy: "LinkSnappy"
};
const statusLabels: Record<HistoryViewStatus, string> = {
completed: "Abgeschlossen",
deleted: "Gelöscht",
failed: "Fehlgeschlagen"
};
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
function formatBytes(bytes: number): string {
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
if (safe < 1024) {
return `${Math.round(safe)} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = safe / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatDuration(durationSeconds: number): string {
const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function localDayStart(timestamp: number): number {
const date = new Date(timestamp);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, now: number): boolean {
if (filter === "completed" || filter === "deleted" || filter === "failed") {
return entry.status === filter;
}
if (filter === "all") {
return true;
}
const todayStart = localDayStart(now);
const tomorrowStartDate = new Date(todayStart);
tomorrowStartDate.setDate(tomorrowStartDate.getDate() + 1);
const tomorrowStart = tomorrowStartDate.getTime();
const weekStartDate = new Date(todayStart);
weekStartDate.setDate(weekStartDate.getDate() - 6);
const weekStart = weekStartDate.getTime();
if (filter === "today") {
return entry.completedAt >= todayStart && entry.completedAt < tomorrowStart;
}
if (filter === "week") {
return entry.completedAt >= weekStart && entry.completedAt < todayStart;
}
return entry.completedAt < weekStart;
}
function normalizeSearch(value: string): string {
return value.trim().toLocaleLowerCase("de-DE");
}
export function deriveHistoryHoster(urls: string[] | undefined): string {
const hostnames: string[] = [];
const seen = new Set<string>();
for (const raw of urls ?? []) {
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") {
continue;
}
const hostname = url.hostname.toLocaleLowerCase("de-DE");
if (!hostname || seen.has(hostname)) {
continue;
}
seen.add(hostname);
hostnames.push(hostname);
} catch {
continue;
}
}
return hostnames.length > 0 ? hostnames.join(", ") : "—";
}
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0);
const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000;
return Math.max(0, completedAt - durationMs);
}
function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
const hoster = deriveHistoryHoster(entry.urls);
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
const startAt = deriveHistoryStartAt(entry);
const durationSeconds = Math.max(0, entry.durationSeconds || 0);
const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0;
return {
...entry,
hoster,
providerLabel,
startAt,
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
startedLabel: dateFormatter.format(new Date(startAt)),
completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))),
durationLabel: formatDuration(durationSeconds),
averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status]
};
}
export function filterHistoryRows(
entries: HistoryViewEntry[],
filter: HistoryFilter,
query: string,
now = Date.now()
): HistoryRow[] {
const normalizedQuery = normalizeSearch(query);
return entries
.filter((entry) => matchesTemporalFilter(entry, filter, now))
.map(toHistoryRow)
.filter((row) => {
if (!normalizedQuery) {
return true;
}
const searchable = [
row.name,
row.outputDir,
row.hoster,
row.providerLabel,
...(row.urls ?? [])
].join("\n").toLocaleLowerCase("de-DE");
return searchable.includes(normalizedQuery);
});
}
function countHistoryFilters(entries: HistoryViewEntry[], now: number): HistoryFilterCounts {
return {
all: entries.length,
today: entries.filter((entry) => matchesTemporalFilter(entry, "today", now)).length,
week: entries.filter((entry) => matchesTemporalFilter(entry, "week", now)).length,
older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length,
completed: entries.filter((entry) => entry.status === "completed").length,
deleted: entries.filter((entry) => entry.status === "deleted").length,
failed: entries.filter((entry) => entry.status === "failed").length
};
}
export function buildHistoryViewModel(
entries: HistoryViewEntry[],
filter: HistoryFilter,
query: string,
selectedIds: Iterable<string>,
expandedIds: Iterable<string>,
loading: boolean,
error: string,
now = Date.now()
): HistoryViewModel {
const rows = filterHistoryRows(entries, filter, query, now);
const visibleIds = new Set(rows.map((row) => row.id));
return {
rows,
filter,
query,
selectedIds: [...selectedIds].filter((id) => visibleIds.has(id)),
expandedIds: [...expandedIds],
counts: countHistoryFilters(entries, now),
loading,
error,
totalCount: entries.length
};
}
export function pruneHistoryIds(current: Set<string>, availableIds: Iterable<string>): Set<string> {
if (current.size === 0) {
return current;
}
const available = new Set(availableIds);
const next = new Set<string>();
for (const id of current) {
if (available.has(id)) {
next.add(id);
}
}
return next.size === current.size ? current : next;
}
export function selectVisibleHistoryIds(visibleIds: Iterable<string>): Set<string> {
return new Set(visibleIds);
}
+366
View File
@@ -0,0 +1,366 @@
.history-workspace-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.history-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
min-width: 0;
}
.history-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 100%;
padding: 12px;
}
.history-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 28px;
}
.history-filter-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.history-filter,
.history-sidebar-clear,
.history-action {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--ui-text-secondary);
display: flex;
font: inherit;
height: 36px;
}
.history-filter {
justify-content: space-between;
padding: 0 10px;
text-align: left;
width: 100%;
}
.history-filter span:last-child {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.history-filter:hover,
.history-sidebar-clear:hover:not(:disabled),
.history-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.history-filter.is-active {
background: var(--ui-active);
border-color: var(--ui-border);
color: var(--ui-text);
}
.history-sidebar-clear {
border-color: var(--ui-border);
justify-content: flex-start;
padding: 0 10px;
width: 100%;
}
.history-sidebar-clear:disabled,
.history-action:disabled {
cursor: default;
opacity: 0.45;
}
.history-sidebar,
.history-workspace-toolbar,
.history-content,
.history-pagination {
user-select: none;
}
.history-workspace-toolbar {
min-width: 0;
width: 100%;
}
.history-workspace-toolbar .ui-toolbar-search-input {
user-select: text;
}
.history-action {
background: var(--ui-input);
border-color: var(--ui-border);
padding: 0 12px;
white-space: nowrap;
}
.history-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.history-content {
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.history-content .history-table {
height: 100%;
overflow: auto;
}
.history-table-header-row,
.history-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px;
min-width: 1080px;
}
.history-table > .history-table-header {
height: 41px;
position: sticky;
top: 0;
z-index: 1;
}
.history-table-header-row {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.history-table-header-row > span,
.history-row > span {
min-width: 0;
padding: 0 10px;
}
.history-table > .history-table-body {
overflow: visible;
}
.history-row-group {
min-width: 1080px;
}
.history-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.history-row:hover {
background: var(--ui-hover);
}
.history-row.is-selected {
background: var(--ui-active);
}
.history-column-select {
display: grid;
place-items: center;
}
.history-row-name {
align-items: center;
color: var(--ui-text);
display: flex;
font-weight: 600;
gap: 8px;
overflow: hidden;
}
.history-row-name > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-expand,
.history-row-action button {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--ui-text-secondary);
flex: 0 0 auto;
font: inherit;
height: 28px;
}
.history-expand {
width: 28px;
}
.history-row-action button {
width: 36px;
}
.history-expand:hover,
.history-row-action button:hover {
background: var(--ui-hover);
border-color: var(--ui-border);
color: var(--ui-text);
}
.history-status {
border: 1px solid var(--ui-border);
border-radius: 999px;
display: inline-flex;
font-size: 12px;
font-weight: 600;
line-height: 22px;
max-width: 100%;
overflow: hidden;
padding: 0 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-status-completed {
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
color: var(--ui-success);
}
.history-status-deleted {
background: color-mix(in srgb, var(--ui-warning) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
color: var(--ui-warning);
}
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
color: var(--ui-danger);
}
.history-row-size,
.history-row-hoster,
.history-row-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-row-size,
.history-row-time {
font-variant-numeric: tabular-nums;
}
.history-row-action {
display: grid;
place-items: center;
}
.history-detail-row {
border-bottom: 1px solid var(--ui-border);
background: var(--ui-input);
min-width: 1080px;
}
.history-detail-cell {
padding: 14px 48px;
}
.history-details-grid {
display: grid;
gap: 12px 24px;
grid-template-columns: repeat(4, minmax(120px, 1fr));
margin: 0;
}
.history-details-grid > div {
display: grid;
gap: 4px;
min-width: 0;
}
.history-details-grid dt {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.history-details-grid dd {
color: var(--ui-text-secondary);
margin: 0;
min-width: 0;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-copyable {
overflow-wrap: anywhere;
user-select: text;
white-space: pre-wrap;
}
.history-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.history-pagination {
height: 60px;
}
@media (max-width: 1366px) {
.history-workspace-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.history-sidebar {
overflow: hidden;
}
.history-action {
padding: 0 9px;
}
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px;
min-width: 996px;
}
.history-row-group,
.history-detail-row {
min-width: 996px;
}
}
@media (max-width: 1120px) {
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px;
min-width: 932px;
}
.history-row-group,
.history-detail-row {
min-width: 932px;
}
}
@@ -0,0 +1,585 @@
import {
cloneElement,
type ChangeEvent,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
type ReactElement,
type UIEvent
} from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
import {
ACCOUNT_COLUMNS,
type AccountAddFilter,
type AccountAddOption,
type AccountRowViewModel
} from "./settings-model";
export type AccountWorkspacePanel = "overview" | "rules";
export interface AccountRulesViewModel {
providerOrder: readonly string[];
routing: readonly string[];
autoFallback: boolean;
rememberCredentials?: boolean;
rotationEvents?: readonly { id: string; title: string; detail: string }[];
routingEntries?: readonly {
hosterId: string;
hosterLabel: string;
provider: string;
providers: readonly { value: string; label: string }[];
}[];
availableRoutingHosters?: readonly { value: string; label: string }[];
}
export interface AccountWorkspaceViewModel {
activePanel: AccountWorkspacePanel;
rows: readonly AccountRowViewModel[];
selectedIds: readonly string[];
busy: boolean;
error?: string;
allEnabled?: boolean;
statusSort?: "none" | "desc" | "asc";
rules: AccountRulesViewModel;
}
export interface AccountWorkspaceActions {
onPanelChange: (panel: AccountWorkspacePanel) => void;
onSelect: (rowId: string) => void;
onToggleEnabled: (rowId: string) => void;
onEdit: (rowId: string) => void;
onContextMenu: (rowId: string, x: number, y: number) => void;
onAdd: () => void;
onRemoveSelected: () => void;
onCheckAll: () => void;
onSetAllEnabled?: (enabled: boolean) => void;
onStatusSort?: () => void;
onMoveProvider?: (index: number, direction: -1 | 1) => void;
onProviderDragStart?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDragOver?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDrop?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDragEnd?: () => void;
onToggleAutoFallback?: (enabled: boolean) => void;
onToggleRememberCredentials?: (enabled: boolean) => void;
onRoutingProviderChange?: (hosterId: string, provider: string) => void;
onRoutingRemove?: (hosterId: string) => void;
onRoutingAdd?: (hosterId: string) => void;
}
export interface AccountWorkspaceProps {
model: AccountWorkspaceViewModel;
actions: AccountWorkspaceActions;
}
export interface AccountDialogField {
id: string;
label: string;
type: "text" | "password" | "number" | "textarea";
value: string;
placeholder?: string;
help?: string;
}
export interface AccountAddDialogModel {
open: boolean;
query: string;
filter: AccountAddFilter;
options: readonly AccountAddOption[];
selectedOptionId: string | null;
fields: readonly AccountDialogField[];
error: string;
busy: boolean;
}
export interface AccountAddDialogActions {
onQueryChange: (value: string) => void;
onFilterChange: (filter: AccountAddFilter) => void;
onOptionSelect: (optionId: string) => void;
onFieldChange: (fieldId: string, value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export interface AccountEditDialogModel {
open: boolean;
hoster: string;
mode: string;
identity: string;
enabled: boolean;
fields: readonly AccountDialogField[];
error: string;
busy: boolean;
}
export interface AccountEditDialogActions {
onFieldChange: (fieldId: string, value: string) => void;
onClose: () => void;
onCheck: () => void;
onSave: () => void;
onRemove: () => void;
onToggleEnabled: () => void;
}
function AccountDialogFields({
fields,
onChange
}: {
fields: readonly AccountDialogField[];
onChange: (fieldId: string, value: string) => void;
}): ReactElement {
return (
<div className="settings-account-dialog-fields">
{fields.map((field) => (
<label className="settings-account-dialog-field" key={field.id}>
<span>{field.label}</span>
{field.type === "textarea" ? (
<textarea
className="settings-control settings-account-dialog-textarea"
onChange={(event) => onChange(field.id, event.target.value)}
placeholder={field.placeholder}
rows={4}
value={field.value}
/>
) : (
<input
autoComplete={field.type === "password" ? "off" : undefined}
className="settings-control"
inputMode={field.type === "number" ? "decimal" : undefined}
onChange={(event) => onChange(field.id, event.target.value)}
placeholder={field.placeholder}
type={field.type}
value={field.value}
/>
)}
{field.help ? <span className="settings-field-help">{field.help}</span> : null}
</label>
))}
</div>
);
}
function AccountRow({
row,
selected,
busy,
actions
}: {
row: AccountRowViewModel;
selected: boolean;
busy: boolean;
actions: AccountWorkspaceActions;
}): ReactElement {
const selectRow = (): void => actions.onSelect(row.id);
const onClick = (): void => selectRow();
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) {
return;
}
event.preventDefault();
selectRow();
};
const openContextMenu = (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
actions.onContextMenu(row.id, event.clientX, event.clientY);
};
return (
<div
aria-selected={selected}
className={`settings-account-row${selected ? " is-selected" : ""}${row.problem ? " has-problem" : ""}${!row.enabled ? " is-disabled" : ""}`}
onClick={onClick}
onContextMenu={openContextMenu}
onDoubleClick={() => actions.onEdit(row.id)}
onKeyDown={onKeyDown}
role="row"
tabIndex={0}
>
<span className="settings-account-column-enable" role="cell">
<input
aria-label={`${row.hoster} ${row.enabled ? "deaktivieren" : "aktivieren"}`}
checked={row.enabled}
disabled={busy}
onChange={() => actions.onToggleEnabled(row.id)}
onClick={(event) => event.stopPropagation()}
type="checkbox"
/>
</span>
<span className="settings-account-hoster" role="cell" title={`${row.hoster} · ${row.mode}`}>
<img alt="" aria-hidden="true" draggable={false} height="20" src={row.icon} width="20" />
<span>
<strong>{row.hoster}</strong>
<small>{row.mode}</small>
</span>
</span>
<span className="settings-account-status" role="cell">
<span className={`settings-account-status-badge is-${row.status.tone}`}>{row.status.text}</span>
</span>
<span className="settings-account-traffic" role="cell">{row.traffic}</span>
<span className="settings-account-username settings-copyable" role="cell" title={row.username}>{row.username}</span>
<span className="settings-account-expires" role="cell">{row.expires}</span>
<span className="settings-account-credential" role="cell">{row.credential}</span>
<span className="settings-account-column-actions" role="cell">
<button
aria-label={`${row.hoster} Aktionen`}
className="settings-account-action-button"
disabled={busy}
onClick={(event) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
actions.onContextMenu(row.id, rect.right, rect.bottom);
}}
type="button"
></button>
</span>
</div>
);
}
function syncAccountTableScroll(event: UIEvent<HTMLDivElement>): void {
const header = event.currentTarget.parentElement?.querySelector<HTMLElement>(".settings-account-table-header");
if (header) {
header.scrollLeft = event.currentTarget.scrollLeft;
}
}
function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement {
const selectedIds = new Set(model.selectedIds);
return (
<>
<DataTable className="settings-account-table" label="Accounts">
<DataTableHeader className="settings-account-table-header">
<div className="settings-account-table-grid" role="row">
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
{ACCOUNT_COLUMNS.map((column) => (
<span key={column} role="columnheader">
{column === "Status" && actions.onStatusSort ? (
<button className="settings-account-sort" onClick={actions.onStatusSort} type="button">
{column}{model.statusSort === "desc" ? " ▼" : model.statusSort === "asc" ? " ▲" : ""}
</button>
) : column}
</span>
))}
<span aria-label="Aktionen" className="settings-account-column-actions" role="columnheader" />
</div>
</DataTableHeader>
<DataTableBody className="settings-account-table-body" data-visual-region="accounts-table-body" onScroll={syncAccountTableScroll}>
{model.busy && model.rows.length === 0 ? (
<DataTableEmpty description="Die Accountdaten werden aktualisiert." title="Accounts werden geladen" />
) : model.error ? (
<DataTableEmpty className="settings-account-table-error" description="Die gespeicherten Accounts bleiben unverändert." title={model.error} />
) : model.rows.length === 0 ? (
<DataTableEmpty description="Füge einen Account hinzu, um Downloads über einen Anbieter zu starten." title="Noch keine Accounts" />
) : model.rows.map((row) => cloneElement(
AccountRow({ actions, busy: model.busy, row, selected: selectedIds.has(row.id) }),
{ key: row.id }
))}
</DataTableBody>
</DataTable>
<div className="settings-account-local-actions">
<div>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onAdd} type="button"> Hinzufügen</button>
<button className="settings-button settings-button-secondary" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button"> Entfernen</button>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheckAll} type="button"> Aktualisieren</button>
</div>
<span>{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"}</span>
</div>
</>
);
}
function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-rules">
<section className="settings-rule-section">
<h3>Provider-Reihenfolge</h3>
<p>Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.</p>
{model.rules.providerOrder.length === 0 ? (
<span className="settings-rule-empty">Keine Provider konfiguriert.</span>
) : (
<ol className="settings-provider-order">
{model.rules.providerOrder.map((provider, index) => (
<li
draggable={Boolean(actions.onProviderDragStart)}
key={`${provider}-${index}`}
onDragEnd={actions.onProviderDragEnd}
onDragOver={(event) => actions.onProviderDragOver?.(event, index)}
onDragStart={(event) => actions.onProviderDragStart?.(event, index)}
onDrop={(event) => actions.onProviderDrop?.(event, index)}
>
<span>{provider}</span>
{actions.onMoveProvider ? (
<span className="settings-provider-order-actions">
<button aria-label={`${provider} nach oben`} disabled={index === 0} onClick={() => actions.onMoveProvider?.(index, -1)} type="button"></button>
<button aria-label={`${provider} nach unten`} disabled={index === model.rules.providerOrder.length - 1} onClick={() => actions.onMoveProvider?.(index, 1)} type="button"></button>
</span>
) : null}
</li>
))}
</ol>
)}
<label className="settings-rule-toggle">
<input
checked={model.rules.autoFallback}
disabled={!actions.onToggleAutoFallback}
onChange={(event) => actions.onToggleAutoFallback?.(event.target.checked)}
type="checkbox"
/>
<span>Automatischer Fallback</span>
</label>
{typeof model.rules.rememberCredentials === "boolean" ? (
<label className="settings-rule-toggle">
<input
checked={model.rules.rememberCredentials}
disabled={!actions.onToggleRememberCredentials}
onChange={(event) => actions.onToggleRememberCredentials?.(event.target.checked)}
type="checkbox"
/>
<span>Zugangsdaten lokal speichern</span>
</label>
) : null}
</section>
<section className="settings-rule-section">
<h3>Hoster-Routing</h3>
<p>Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.</p>
{model.rules.routingEntries ? (
<>
{model.rules.routingEntries.length === 0 ? <span className="settings-rule-empty">Keine eigenen Zuordnungen.</span> : (
<div className="settings-routing-editor">
{model.rules.routingEntries.map((entry) => (
<div className="settings-routing-editor-row" key={entry.hosterId}>
<span>{entry.hosterLabel}</span>
<select
aria-label={`Provider für ${entry.hosterLabel}`}
className="settings-control"
onChange={(event) => actions.onRoutingProviderChange?.(entry.hosterId, event.target.value)}
value={entry.provider}
>
{entry.providers.map((provider) => <option key={provider.value} value={provider.value}>{provider.label}</option>)}
</select>
<button aria-label={`${entry.hosterLabel} Zuordnung entfernen`} className="settings-button settings-button-danger" onClick={() => actions.onRoutingRemove?.(entry.hosterId)} type="button">Entfernen</button>
</div>
))}
</div>
)}
{model.rules.availableRoutingHosters ? (
<select
aria-label="Hoster-Routing hinzufügen"
className="settings-control settings-routing-add"
onChange={(event) => {
if (event.target.value) {
actions.onRoutingAdd?.(event.target.value);
}
event.target.value = "";
}}
value=""
>
<option disabled value="">Hoster hinzufügen</option>
{model.rules.availableRoutingHosters.map((hoster) => <option key={hoster.value} value={hoster.value}>{hoster.label}</option>)}
<option value="__custom">Eigener Hoster</option>
</select>
) : null}
</>
) : model.rules.routing.length === 0 ? (
<span className="settings-rule-empty">Keine eigenen Zuordnungen.</span>
) : (
<ul className="settings-routing-list">
{model.rules.routing.map((route, index) => <li className="settings-copyable" key={`${route}-${index}`}>{route}</li>)}
</ul>
)}
</section>
{model.rules.rotationEvents ? (
<section className="settings-rule-section">
<h3>Rotations-Verlauf</h3>
{model.rules.rotationEvents.length === 0 ? (
<span className="settings-rule-empty">Noch keine Rotations-Ereignisse.</span>
) : model.rules.rotationEvents.map((event) => (
<div className="settings-rotation-event" key={event.id}>
<strong>{event.title}</strong>
<span>{event.detail}</span>
</div>
))}
</section>
) : null}
</div>
);
}
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-workspace">
<header className="settings-account-heading">
<div>
<h2>Accountverwaltung</h2>
<p>Accounts hinzufügen, prüfen und verwalten.</p>
</div>
{typeof model.allEnabled === "boolean" && actions.onSetAllEnabled ? (
<label className="settings-rule-toggle settings-account-all-enabled">
<input
checked={model.allEnabled}
disabled={model.busy || model.rows.length === 0}
onChange={(event) => actions.onSetAllEnabled?.(event.target.checked)}
type="checkbox"
/>
<span>Accounts zum Herunterladen verwenden</span>
</label>
) : null}
</header>
<div aria-label="Accountverwaltung" className="settings-account-tabs" role="tablist">
<button
aria-controls="settings-account-overview"
aria-selected={model.activePanel === "overview"}
id="settings-account-overview-tab"
onClick={() => actions.onPanelChange("overview")}
role="tab"
type="button"
>Übersicht</button>
<button
aria-controls="settings-account-rules"
aria-selected={model.activePanel === "rules"}
id="settings-account-rules-tab"
onClick={() => actions.onPanelChange("rules")}
role="tab"
type="button"
>Verwendungsregeln</button>
</div>
<div
aria-labelledby="settings-account-overview-tab"
className="settings-account-panel"
hidden={model.activePanel !== "overview"}
id="settings-account-overview"
role="tabpanel"
>
{AccountOverview({ actions, model })}
</div>
<div
aria-labelledby="settings-account-rules-tab"
className="settings-account-panel"
hidden={model.activePanel !== "rules"}
id="settings-account-rules"
role="tabpanel"
>
{AccountRules({ actions, model })}
</div>
</div>
);
}
export function AccountAddDialog({
model,
actions
}: {
model: AccountAddDialogModel;
actions: AccountAddDialogActions;
}): ReactElement | null {
const onFilterChange = (event: ChangeEvent<HTMLSelectElement>): void => {
actions.onFilterChange(event.target.value as AccountAddFilter);
};
return (
<Dialog
actions={(
<>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onClose} type="button">Abbrechen</button>
<button className="settings-button settings-button-primary" disabled={model.busy || !model.selectedOptionId} onClick={actions.onSubmit} type="button">Prüfen und speichern</button>
</>
)}
actionsClassName="settings-account-dialog-actions"
bodyClassName="settings-account-dialog-body"
description="Wähle einen Dienst und trage die passenden Zugangsdaten ein."
onClose={actions.onClose}
open={model.open}
size="account"
title="Account hinzufügen"
>
<div className="settings-account-picker-controls">
<input
aria-label="Accounts durchsuchen"
className="settings-control"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Dienst oder Zugangstyp suchen"
type="search"
value={model.query}
/>
<select aria-label="Account-Typ filtern" className="settings-control" onChange={onFilterChange} value={model.filter}>
<option value="all">Alle</option>
<option value="api">API</option>
<option value="web">Web</option>
</select>
</div>
<div aria-label="Verfügbare Account-Typen" className="settings-account-picker" role="listbox">
{model.options.length === 0 ? (
<span className="settings-account-picker-empty">Keine passenden Account-Typen.</span>
) : model.options.map((option) => {
const selected = option.id === model.selectedOptionId;
return (
<div className="settings-account-picker-entry" key={option.id}>
<button
aria-selected={selected}
className={`settings-account-picker-row${selected ? " is-selected" : ""}`}
onClick={() => actions.onOptionSelect(option.id)}
role="option"
type="button"
>
<span>
<strong>{option.title}</strong>
<small>{option.description}</small>
</span>
<span>
<strong>{option.mode}</strong>
<small>{option.functionLabel}</small>
</span>
</button>
{selected ? <AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} /> : null}
</div>
);
})}
</div>
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
</Dialog>
);
}
export function AccountEditDialog({
model,
actions
}: {
model: AccountEditDialogModel;
actions: AccountEditDialogActions;
}): ReactElement | null {
return (
<Dialog
actions={(
<>
<button className="settings-button settings-button-danger" disabled={model.busy} onClick={actions.onRemove} type="button">Entfernen</button>
<span className="settings-account-dialog-action-spacer" />
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onClose} type="button">Abbrechen</button>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheck} type="button">Prüfen</button>
<button className="settings-button settings-button-primary" disabled={model.busy} onClick={actions.onSave} type="button">Speichern</button>
</>
)}
actionsClassName="settings-account-dialog-actions"
bodyClassName="settings-account-dialog-body"
description="Bearbeite ausschließlich den ausgewählten Account."
onClose={actions.onClose}
open={model.open}
size="account"
title="Account bearbeiten"
>
<div className="settings-account-edit-identity">
<span>{model.hoster} · {model.mode}</span>
<strong className="settings-copyable">{model.identity}</strong>
</div>
<label className="settings-rule-toggle settings-account-edit-enabled">
<input checked={model.enabled} disabled={model.busy} onChange={actions.onToggleEnabled} type="checkbox" />
<span>Account aktiviert</span>
</label>
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
</Dialog>
);
}
@@ -0,0 +1,177 @@
import { cloneElement, type ChangeEvent, type ReactElement } from "react";
import type {
SettingsFieldViewModel,
SettingsFormViewModel,
SettingsTextFieldViewModel
} from "./settings-model";
export interface SettingsFormActions {
onChange: (fieldId: string, value: string | boolean) => void;
onAction: (fieldId: string) => void;
onCommit?: (fieldId: string, value: string) => void;
}
export interface SettingsFormProps {
model: SettingsFormViewModel;
actions: SettingsFormActions;
}
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
}
function TextControl({ field, actions }: { field: SettingsTextFieldViewModel; actions: SettingsFormActions }): ReactElement {
const describedBy = field.help ? `${field.id}-help` : undefined;
const onChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
actions.onChange(field.id, event.target.value);
};
const control = field.kind === "textarea" ? (
<textarea
aria-describedby={describedBy}
className="settings-control settings-textarea"
disabled={field.disabled}
id={field.id}
onChange={onChange}
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
placeholder={field.placeholder}
rows={4}
value={field.value}
/>
) : (
<input
aria-describedby={describedBy}
className={`settings-control${field.kind === "path" ? " settings-copyable" : ""}`}
disabled={field.disabled}
id={field.id}
inputMode={field.inputMode}
max={field.max}
min={field.min}
onChange={onChange}
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
placeholder={field.placeholder}
step={field.step}
type={field.kind === "number" ? "number" : "text"}
value={field.value}
/>
);
return (
<div className="settings-field">
<label htmlFor={field.id}>{field.label}</label>
{field.actionLabel ? (
<div className="settings-control-row">
{control}
<button
className="settings-button settings-button-secondary"
disabled={field.disabled}
onClick={() => actions.onAction(field.id)}
type="button"
>{field.actionLabel}</button>
</div>
) : control}
<FieldHelp field={field} />
</div>
);
}
function SettingsField({ field, actions }: { field: SettingsFieldViewModel; actions: SettingsFormActions }): ReactElement {
if (field.kind === "text" || field.kind === "path" || field.kind === "number" || field.kind === "textarea") {
return <TextControl actions={actions} field={field} />;
}
if (field.kind === "select") {
return (
<div className="settings-field">
<label htmlFor={field.id}>{field.label}</label>
<select
aria-describedby={field.help ? `${field.id}-help` : undefined}
className="settings-control"
disabled={field.disabled}
id={field.id}
onChange={(event) => actions.onChange(field.id, event.target.value)}
value={field.value}
>
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<FieldHelp field={field} />
</div>
);
}
if (field.kind === "theme") {
return (
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
<legend>{field.label}</legend>
<div aria-describedby={field.help ? `${field.id}-help` : undefined} className="settings-theme-options" role="radiogroup">
{field.options.map((option) => (
<button
aria-checked={field.value === option.value}
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
key={option.value}
onClick={() => actions.onChange(field.id, option.value)}
role="radio"
type="button"
>
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
<span>{option.label}</span>
</button>
))}
</div>
<FieldHelp field={field} />
</fieldset>
);
}
if (field.kind === "switch") {
return (
<div className="settings-field settings-switch-field">
<div>
<span className="settings-switch-label" id={`${field.id}-label`}>{field.label}</span>
<FieldHelp field={field} />
</div>
<button
aria-checked={field.value}
aria-describedby={field.help ? `${field.id}-help` : undefined}
aria-labelledby={`${field.id}-label`}
className={`settings-switch${field.value ? " is-on" : ""}`}
disabled={field.disabled}
onClick={() => actions.onChange(field.id, !field.value)}
role="switch"
type="button"
><span /></button>
</div>
);
}
return (
<div className="settings-field settings-action-field">
<div>
<span className="settings-switch-label">{field.label}</span>
<FieldHelp field={field} />
</div>
<button
className="settings-button settings-button-secondary"
disabled={field.disabled}
onClick={() => actions.onAction(field.id)}
type="button"
>{field.actionLabel}</button>
</div>
);
}
export function SettingsForm({ model, actions }: SettingsFormProps): ReactElement {
return (
<div className="settings-form-column">
<header className="settings-form-heading">
<h2>{model.title}</h2>
<p>{model.description}</p>
</header>
{model.groups.map((group) => (
<section className="settings-form-group" key={group.id}>
<header>
<h3>{group.title}</h3>
{group.description ? <p>{group.description}</p> : null}
</header>
<div className="settings-form-fields">
{group.fields.map((field) => cloneElement(SettingsField({ actions, field }), { key: field.id }))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,99 @@
import type { ReactElement } from "react";
import {
SETTINGS_SECTIONS,
getSettingsSaveLabel,
type SettingsFormViewModel,
type SettingsSaveState,
type SettingsSection
} from "./settings-model";
import {
AccountWorkspace,
type AccountWorkspaceActions,
type AccountWorkspaceViewModel
} from "./AccountWorkspace";
import { SettingsForm, type SettingsFormActions } from "./SettingsForm";
import "./settings.css";
export type SettingsViewRegion = "all" | "sidebar" | "content";
export interface SettingsViewModel {
section: SettingsSection;
saveState: SettingsSaveState;
form: SettingsFormViewModel;
accounts: AccountWorkspaceViewModel;
}
export interface SettingsViewActions {
onSectionChange: (section: SettingsSection) => void;
onSave: () => void;
form: SettingsFormActions;
accounts: AccountWorkspaceActions;
}
export interface SettingsViewProps {
model: SettingsViewModel;
actions: SettingsViewActions;
region?: SettingsViewRegion;
}
export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactElement {
return (
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
<div className="settings-sidebar-heading">
<strong>Einstellungen</strong>
</div>
<div className="settings-sidebar-list">
{SETTINGS_SECTIONS.map((section) => (
<button
aria-current={model.section === section.id ? "page" : undefined}
className={`settings-sidebar-item${model.section === section.id ? " is-active" : ""}`}
key={section.id}
onClick={() => actions.onSectionChange(section.id)}
type="button"
>{section.label}</button>
))}
</div>
</nav>
);
}
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
const saveLabel = getSettingsSaveLabel(model.saveState);
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
return (
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
<header className="settings-content-header">
<div>
<h1>Einstellungen</h1>
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
</div>
<button
className="settings-button settings-button-primary"
disabled={saveDisabled}
onClick={actions.onSave}
type="button"
>Einstellungen speichern</button>
</header>
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
{model.section === "accounts"
? <AccountWorkspace actions={actions.accounts} model={model.accounts} />
: <SettingsForm actions={actions.form} model={model.form} />}
</div>
</section>
);
}
export function SettingsView({ model, actions, region = "all" }: SettingsViewProps): ReactElement {
if (region === "sidebar") {
return <SettingsSidebar actions={actions} model={model} />;
}
if (region === "content") {
return <SettingsContent actions={actions} model={model} />;
}
return (
<div className="settings-view">
<SettingsSidebar actions={actions} model={model} />
<SettingsContent actions={actions} model={model} />
</div>
);
}
@@ -0,0 +1,652 @@
import type { AppSettings } from "../../../shared/types";
import type { AccountService } from "../../account-edit";
import { resolveAccountUsername } from "../../account-ui";
import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
export type SettingsSaveState = "clean" | "dirty" | "saving" | "saved" | "error";
export const SETTINGS_SECTIONS: readonly { id: SettingsSection; label: string }[] = [
{ id: "allgemein", label: "Allgemein" },
{ id: "accounts", label: "Accounts" },
{ id: "extract", label: "Entpacken" },
{ id: "speed", label: "Geschwindigkeit" },
{ id: "cleanup", label: "Bereinigung" },
{ id: "updates", label: "Updates" }
];
export const ACCOUNT_COLUMNS = [
"Hoster",
"Status",
"Download-Traffic übrig",
"Benutzername",
"Verfallsdatum",
"Passwort/Zugang"
] as const;
export function getSettingsSaveLabel(state: SettingsSaveState): string {
switch (state) {
case "dirty":
return "Ungespeicherte Änderungen";
case "saving":
return "Wird gespeichert…";
case "error":
return "Speichern fehlgeschlagen";
case "clean":
case "saved":
return "Gespeichert";
}
}
export type AccountStatusSourceState = "premium" | "free" | "invalid" | "checking" | "unchecked" | "disabled";
export type AccountStatusTone = "ok" | "free" | "invalid" | "unknown" | "disabled";
export interface AccountRowSource {
identityId: string;
service: AccountService;
hoster: string;
mode: string;
icon?: string;
enabled: boolean;
status: {
state: AccountStatusSourceState;
message: string;
premiumUntilMs: number | null;
email?: string;
};
dailyLimitBytes?: number;
dailyUsageBytes?: number;
username: string;
credentialKind: "password" | "api-key" | "protected";
canCheck: boolean;
}
export interface AccountRowViewModel {
id: string;
service: AccountService;
hoster: string;
mode: string;
icon: string;
enabled: boolean;
selected: boolean;
status: {
tone: AccountStatusTone;
text: string;
};
traffic: string;
username: string;
expires: string;
credential: string;
canCheck: boolean;
problem: boolean;
premiumUntilMs: number | null;
}
export type AccountAddFilter = "all" | "api" | "web";
export interface AccountAddOption {
id: string;
service: AccountService;
title: string;
mode: string;
description: string;
functionLabel: string;
filter: Exclude<AccountAddFilter, "all">;
multi: boolean;
icon?: string;
}
export interface AccountAddDraft {
selectedId: string | null;
login: string;
password: string;
token: string;
dailyLimitGb: string;
}
export interface TargetedAccountCheck {
service: "megadebrid-api" | "debridlink";
expectedStatusId: string;
}
export interface SettingsFieldBase {
id: string;
label: string;
help?: string;
disabled?: boolean;
}
export interface SettingsTextFieldViewModel extends SettingsFieldBase {
kind: "text" | "path" | "number" | "textarea";
value: string;
placeholder?: string;
inputMode?: "decimal" | "numeric" | "text" | "url";
min?: number;
max?: number;
step?: number;
actionLabel?: string;
commitOnBlur?: boolean;
}
export interface SettingsSelectFieldViewModel extends SettingsFieldBase {
kind: "select";
value: string;
options: readonly { value: string; label: string }[];
}
export interface SettingsSwitchFieldViewModel extends SettingsFieldBase {
kind: "switch";
value: boolean;
}
export interface SettingsThemeFieldViewModel extends SettingsFieldBase {
kind: "theme";
value: string;
options: readonly { value: string; label: string }[];
}
export interface SettingsActionFieldViewModel extends SettingsFieldBase {
kind: "action";
actionLabel: string;
}
export type SettingsFieldViewModel =
| SettingsTextFieldViewModel
| SettingsSelectFieldViewModel
| SettingsSwitchFieldViewModel
| SettingsThemeFieldViewModel
| SettingsActionFieldViewModel;
export interface SettingsFormGroupViewModel {
id: string;
title: string;
description?: string;
fields: readonly SettingsFieldViewModel[];
}
export interface SettingsFormViewModel {
title: string;
description: string;
groups: readonly SettingsFormGroupViewModel[];
}
export interface SettingsFormProjectionInput {
settings: AppSettings;
section: SettingsSection;
speedLimitInput: string;
scheduleSpeedInputs: Readonly<Record<string, string>>;
themeChoice?: "light" | "dark" | "system";
}
export function buildSettingsFormViewModel({
settings,
section,
speedLimitInput,
scheduleSpeedInputs,
themeChoice = settings.theme
}: SettingsFormProjectionInput): SettingsFormViewModel {
if (section === "extract") {
return {
title: "Entpacken",
description: "Ablauf, Ziel, Tonspur, Ablageform und Leistung.",
groups: [
{
id: "extract-target",
title: "Ziel und Ablauf",
fields: [
{ id: "extractDir", kind: "path", label: "Entpacken nach", value: settings.extractDir, actionLabel: "Wählen", help: "Zielordner für entpackte Dateien." },
{ id: "autoExtract", kind: "switch", label: "Automatisch entpacken", value: settings.autoExtract },
{ id: "autoSkipExtracted", kind: "switch", label: "Bereits Entpacktes überspringen", value: settings.autoSkipExtracted },
{ id: "hideExtractedItems", kind: "switch", label: "Entpackte Einträge ausblenden", value: settings.hideExtractedItems },
{ id: "autoExtractWhenStopped", kind: "switch", label: "Entpacken auch ohne laufende Sitzung", value: settings.autoExtractWhenStopped }
]
},
{
id: "extract-audio",
title: "Deutsche Tonspur",
fields: [
{ id: "keepGermanAudioOnly", kind: "switch", label: "Nur deutsche Tonspur behalten", value: settings.keepGermanAudioOnly, help: "Benötigt ffmpeg." },
{
id: "germanAudioMode",
kind: "select",
label: "Welche Tonspur behalten",
value: settings.germanAudioMode,
disabled: !settings.keepGermanAudioOnly,
options: [
{ value: "tag", label: "Deutsche Spur per Sprach-Tag" },
{ value: "first", label: "Immer erste Tonspur" }
]
}
]
},
{
id: "extract-layout",
title: "Ablageform",
fields: [
{ id: "autoRename4sf4sj", kind: "switch", label: "Automatisch umbenennen", value: settings.autoRename4sf4sj },
{ id: "createExtractSubfolder", kind: "switch", label: "In Paket-Unterordner ablegen", value: settings.createExtractSubfolder },
{ id: "collectMkvToLibrary", kind: "switch", label: "Videos in Sammelordner verschieben", value: settings.collectMkvToLibrary },
{ id: "mkvLibraryDir", kind: "path", label: "Video-Sammelordner", value: settings.mkvLibraryDir, disabled: !settings.collectMkvToLibrary, actionLabel: "Wählen" }
]
},
{
id: "extract-performance",
title: "Leistung",
fields: [
{ id: "hybridExtract", kind: "switch", label: "Hybrid-Entpacken", value: settings.hybridExtract },
{ id: "maxParallelExtract", kind: "number", label: "Gleichzeitige Entpackungen", value: String(settings.maxParallelExtract), min: 1, max: 8 },
{
id: "extractCpuPriority",
kind: "select",
label: "CPU-Priorität beim Entpacken",
value: settings.extractCpuPriority,
options: [
{ value: "high", label: "Hoch (80% CPU)" },
{ value: "middle", label: "Mittel (50% CPU)" },
{ value: "low", label: "Niedrig (25% CPU)" }
]
}
]
},
{
id: "extract-passwords",
title: "Passwörter",
fields: [
{ id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: "Ein Passwort pro Zeile" }
]
}
]
};
}
if (section === "speed") {
const scheduleGroups: SettingsFormGroupViewModel[] = (settings.bandwidthSchedules || []).map((schedule, index) => {
const scheduleKey = schedule.id || `schedule-${index}`;
return {
id: `schedule:${index}`,
title: `Zeitregel ${index + 1}`,
fields: [
{ id: `schedule:${index}:startHour`, kind: "number", label: "Von (Stunde)", value: String(schedule.startHour), min: 0, max: 23 },
{ id: `schedule:${index}:endHour`, kind: "number", label: "Bis (Stunde)", value: String(schedule.endHour), min: 0, max: 23 },
{
id: `schedule:${index}:speedLimitMbps`,
kind: "number",
label: "Limit (MB/s)",
value: scheduleSpeedInputs[scheduleKey] ?? String(schedule.speedLimitKbps / 1024),
min: 0,
step: 0.1,
commitOnBlur: true
},
{ id: `schedule:${index}:enabled`, kind: "switch", label: "Zeitregel aktiviert", value: schedule.enabled },
{ id: `schedule:${index}:remove`, kind: "action", label: "Zeitregel entfernen", actionLabel: "Entfernen" }
]
};
});
return {
title: "Geschwindigkeit",
description: "Tempo, Wiederverbindung und zeitgesteuerte Bandbreitenregeln.",
groups: [
{
id: "speed-limit",
title: "Tempo-Begrenzung",
fields: [
{ id: "speedLimitEnabled", kind: "switch", label: "Geschwindigkeit begrenzen", value: settings.speedLimitEnabled },
{ id: "speedLimitInput", kind: "number", label: "Höchstgeschwindigkeit (MB/s)", value: speedLimitInput, min: 0, step: 0.1, disabled: !settings.speedLimitEnabled, commitOnBlur: true },
{
id: "speedLimitMode",
kind: "select",
label: "Limit gilt für",
value: settings.speedLimitMode,
disabled: !settings.speedLimitEnabled,
options: [
{ value: "global", label: "Global" },
{ value: "per_download", label: "Pro Download" }
]
}
]
},
{
id: "speed-connection",
title: "Verbindung",
fields: [
{ id: "autoReconnect", kind: "switch", label: "Automatisch neu verbinden", value: settings.autoReconnect },
{ id: "reconnectWaitSeconds", kind: "number", label: "Wartezeit vor neuem Versuch (Sek.)", value: String(settings.reconnectWaitSeconds), min: 10, max: 600 }
]
},
...scheduleGroups,
{
id: "speed-schedule-add",
title: "Bandbreitenplanung",
description: "Jede Regel legt für ein Zeitfenster ein eigenes Limit fest.",
fields: [{ id: "schedule:add", kind: "action", label: "Weitere Zeitregel", actionLabel: "Zeitregel hinzufügen" }]
}
]
};
}
if (section === "cleanup") {
return {
title: "Bereinigung",
description: "Integritätsprüfung und Aufräumen nach Downloads und Entpacken.",
groups: [
{
id: "cleanup-check",
title: "Prüfung",
fields: [{ id: "enableIntegrityCheck", kind: "switch", label: "Dateien auf Fehler prüfen", value: settings.enableIntegrityCheck }]
},
{
id: "cleanup-extract",
title: "Nach dem Entpacken",
fields: [
{ id: "removeLinkFilesAfterExtract", kind: "switch", label: "Link-Dateien danach entfernen", value: settings.removeLinkFilesAfterExtract },
{ id: "removeSamplesAfterExtract", kind: "switch", label: "Vorschau-Dateien danach entfernen", value: settings.removeSamplesAfterExtract },
{
id: "cleanupMode",
kind: "select",
label: "Archive nach dem Entpacken",
value: settings.cleanupMode,
options: [
{ value: "none", label: "Keine Archive löschen" },
{ value: "trash", label: "Archive in Papierkorb" },
{ value: "delete", label: "Archive löschen" }
]
}
]
},
{
id: "cleanup-finished",
title: "Fertige Downloads und Konflikte",
fields: [
{
id: "completedCleanupPolicy",
kind: "select",
label: "Fertige Downloads aus der Liste",
value: settings.completedCleanupPolicy,
options: [
{ value: "never", label: "Nie" },
{ value: "immediate", label: "Sofort" },
{ value: "on_start", label: "Beim App-Start" },
{ value: "package_done", label: "Sobald Paket fertig ist" }
]
},
{
id: "extractConflictMode",
kind: "select",
label: "Bei gleichnamigen Dateien",
value: settings.extractConflictMode,
options: [
{ value: "overwrite", label: "Überschreiben" },
{ value: "skip", label: "Überspringen" },
{ value: "rename", label: "Umbenennen" },
{ value: "ask", label: "Nachfragen" }
]
}
]
}
]
};
}
if (section === "updates") {
return {
title: "Updates",
description: "Quelle und Zeitpunkt der Update-Prüfung.",
groups: [
{
id: "updates-main",
title: "Aktualisierung",
fields: [
{ id: "autoUpdateCheck", kind: "switch", label: "Beim Start nach Updates suchen", value: settings.autoUpdateCheck },
{ id: "updateRepo", kind: "text", label: "Update-Quelle", value: settings.updateRepo, help: "Quelle im Format Benutzer/Repository." },
{ id: "update:check", kind: "action", label: "Jetzt nach einer neuen Version suchen", actionLabel: "Nach Updates suchen" }
]
}
]
};
}
if (section === "accounts") {
return { title: "Accounts", description: "Accounts und Verwendungsregeln.", groups: [] };
}
return {
title: "Allgemein",
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
groups: [
{
id: "general-storage",
title: "Speicherort",
fields: [
{ id: "outputDir", kind: "path", label: "Download-Ordner", value: settings.outputDir, actionLabel: "Wählen", help: "Zielordner für heruntergeladene Dateien." },
{ id: "packageName", kind: "text", label: "Paketname (optional)", value: settings.packageName }
]
},
{
id: "general-downloads",
title: "Download-Verhalten",
fields: [
{ id: "maxParallel", kind: "number", label: "Max. gleichzeitige Downloads", value: String(settings.maxParallel), min: 1, max: 50 },
{ id: "retryLimit", kind: "number", label: "Automatische Wiederholungen", value: String(settings.retryLimit), min: 0, max: 99 },
{ id: "autoResumeOnStart", kind: "switch", label: "Beim Start automatisch fortsetzen", value: settings.autoResumeOnStart },
{ id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch }
]
},
{
id: "general-history",
title: "Verlauf",
fields: [
{
id: "historyRetentionMode",
kind: "select",
label: "Verlauf speichern",
value: settings.historyRetentionMode,
options: [
{ value: "never", label: "Nie" },
{ value: "session", label: "Nur aktuelle Session" },
{ value: "permanent", label: "Dauerhaft" }
]
},
{ id: "historyMaxEntries", kind: "number", label: "Maximale Verlauf-Einträge", value: String(settings.historyMaxEntries), min: 50, max: 100000, disabled: settings.historyRetentionMode !== "permanent" },
{ id: "historyMaxAgeDays", kind: "number", label: "Einträge löschen älter als (Tage)", value: String(settings.historyMaxAgeDays), min: 0, max: 3650, disabled: settings.historyRetentionMode !== "permanent" }
]
},
{
id: "general-interface",
title: "Oberfläche und Bedienung",
fields: [
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
{ id: "autoSortPackagesByProgress", kind: "switch", label: "Nach Fortschritt sortieren", value: settings.autoSortPackagesByProgress },
{ id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray },
{ id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
{ id: "backupIncludeRemoteDiagnostics", kind: "switch", label: "Ferndiagnose-Einstellungen mitsichern", value: settings.backupIncludeRemoteDiagnostics },
{
id: "theme",
kind: "theme",
label: "Theme",
value: themeChoice,
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" }
]
}
]
},
{
id: "general-notifications",
title: "Discord-Benachrichtigungen",
fields: [
{ id: "notifyUrl", kind: "text", label: "Webhook-Adresse", value: settings.notifyUrl, placeholder: "https://discord.com/api/webhooks/…", actionLabel: "Testen" },
{ id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention },
{ id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted },
{ id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed },
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn alles fertig ist", value: settings.notifyOnRunFinished }
]
}
]
};
}
export function buildAccountRowId(service: AccountService, mode: string, identityId: string): string {
return [service, mode, identityId].map((part) => encodeURIComponent(part)).join("::");
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / (1024 ** unitIndex);
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value)} ${units[unitIndex]}`;
}
function formatTraffic(limitBytes?: number, usageBytes?: number): string {
if (!Number.isFinite(limitBytes) || !limitBytes || limitBytes <= 0) {
return "Unbeschränkt";
}
const safeUsage = Number.isFinite(usageBytes) && usageBytes && usageBytes > 0 ? usageBytes : 0;
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig`;
}
function formatExpiry(premiumUntilMs: number | null): string {
if (!premiumUntilMs || !Number.isFinite(premiumUntilMs) || premiumUntilMs <= 0) {
return "—";
}
return new Intl.DateTimeFormat("de-DE").format(new Date(premiumUntilMs));
}
function projectStatus(source: AccountRowSource): { tone: AccountStatusTone; text: string } {
if (!source.enabled || source.status.state === "disabled") {
return { tone: "disabled", text: "Deaktiviert" };
}
switch (source.status.state) {
case "premium":
return { tone: "ok", text: source.status.message.trim() || "Premium Account" };
case "free":
return { tone: "free", text: "Free Account" };
case "invalid":
return { tone: "invalid", text: source.status.message.trim() || "Zugang ungültig" };
case "checking":
return { tone: "unknown", text: "Prüft…" };
case "unchecked":
return { tone: "unknown", text: "Noch nicht geprüft" };
}
}
function projectCredential(kind: AccountRowSource["credentialKind"]): string {
if (kind === "api-key") {
return "API-Key";
}
return kind === "password" ? "••••••" : "Geschützter Zugang";
}
export function projectAccountRows(
sources: readonly AccountRowSource[],
selectedIds: readonly string[],
nowMs: number = Date.now()
): AccountRowViewModel[] {
const selected = new Set(selectedIds);
return sources.map((source) => {
const id = buildAccountRowId(source.service, source.mode, source.identityId);
const status = projectStatus(source);
const premiumUntilMs = source.status.premiumUntilMs && source.status.premiumUntilMs > nowMs
? source.status.premiumUntilMs
: null;
return {
id,
service: source.service,
hoster: source.hoster,
mode: source.mode,
icon: ACCOUNT_SERVICE_ICONS[source.service],
enabled: source.enabled,
selected: selected.has(id),
status,
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
username: resolveAccountUsername(source.username, source.status.email),
expires: formatExpiry(source.status.premiumUntilMs),
credential: projectCredential(source.credentialKind),
canCheck: source.canCheck,
problem: status.tone === "invalid",
premiumUntilMs
};
});
}
export function sortAccountRows(
rows: readonly AccountRowViewModel[],
direction: "desc" | "asc" = "desc"
): AccountRowViewModel[] {
return rows
.map((row, index) => ({ row, index }))
.sort((left, right) => {
const leftUntil = left.row.premiumUntilMs ?? -1;
const rightUntil = right.row.premiumUntilMs ?? -1;
if (leftUntil > 0 && rightUntil > 0) {
return (direction === "desc" ? rightUntil - leftUntil : leftUntil - rightUntil) || left.index - right.index;
}
if (leftUntil > 0) {
return -1;
}
if (rightUntil > 0) {
return 1;
}
return left.index - right.index;
})
.map(({ row }) => row);
}
export function pruneAccountSelection(
selectedIds: readonly string[],
rows: readonly Pick<AccountRowViewModel, "id">[]
): string[] {
const existing = new Set(rows.map((row) => row.id));
return [...new Set(selectedIds)].filter((id) => existing.has(id));
}
export function filterAccountAddOptions(
options: readonly AccountAddOption[],
query: string,
filter: AccountAddFilter,
configuredServices: readonly string[]
): AccountAddOption[] {
const normalizedQuery = query.trim().toLocaleLowerCase("de-DE");
const configured = new Set(configuredServices);
return options.filter((option) => {
if (!option.multi && configured.has(option.service)) {
return false;
}
if (filter !== "all" && option.filter !== filter) {
return false;
}
if (!normalizedQuery) {
return true;
}
return [option.title, option.service, option.mode, option.description, option.functionLabel]
.join(" ")
.toLocaleLowerCase("de-DE")
.includes(normalizedQuery);
});
}
export function reconcileAccountAddDraft(
draft: AccountAddDraft,
visibleOptions: readonly Pick<AccountAddOption, "id">[]
): AccountAddDraft {
if (draft.selectedId && visibleOptions.some((option) => option.id === draft.selectedId)) {
return draft;
}
return {
selectedId: null,
login: "",
password: "",
token: "",
dailyLimitGb: ""
};
}
export function buildTargetedAccountCheck(
option: Pick<AccountAddOption, "service">,
identityId: string
): TargetedAccountCheck | null {
if (option.service !== "megadebrid-api" && option.service !== "debridlink") {
return null;
}
return { service: option.service, expectedStatusId: identityId };
}
+876
View File
@@ -0,0 +1,876 @@
.settings-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
}
.settings-sidebar {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 8px;
padding: 12px;
border-right: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.settings-sidebar-heading {
display: flex;
min-height: 32px;
align-items: center;
padding: 0 8px;
color: var(--ui-text-secondary);
font-size: 13px;
}
.settings-sidebar-list {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.settings-sidebar-item {
min-height: 36px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-sidebar-item:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.settings-sidebar-item.is-active {
border-color: var(--ui-border);
background: var(--ui-active);
color: var(--ui-text);
}
.settings-content {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 20px;
padding: 24px;
overflow-y: auto;
background: var(--ui-canvas);
}
.md-runtime-view-content > .settings-content {
height: 100%;
padding: 24px;
}
.settings-static {
user-select: none;
}
.settings-content :where(input, textarea, [contenteditable="true"], .settings-copyable) {
user-select: text;
}
.settings-content-header {
display: flex;
min-width: 0;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.settings-content-header > div {
display: flex;
min-width: 0;
align-items: baseline;
gap: 12px;
}
.settings-content-header h1,
.settings-form-heading h2,
.settings-account-heading h2,
.settings-form-group h3,
.settings-rule-section h3 {
margin: 0;
color: var(--ui-text);
}
.settings-content-header h1 {
font-size: 22px;
line-height: 30px;
}
.settings-save-state {
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-save-state.is-dirty,
.settings-save-state.is-saving {
color: var(--ui-warning);
}
.settings-save-state.is-error {
color: var(--ui-danger);
}
.settings-content-body {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
align-items: flex-start;
}
.settings-content-body.is-accounts {
align-items: stretch;
}
.settings-form-column {
width: 500px;
max-width: 100%;
}
.settings-form-heading {
margin-bottom: 24px;
}
.settings-form-heading h2,
.settings-account-heading h2 {
font-size: 20px;
line-height: 28px;
}
.settings-form-heading p,
.settings-account-heading p,
.settings-form-group > header p,
.settings-rule-section > p {
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 13px;
line-height: 20px;
}
.settings-form-group {
padding: 22px 0;
border-top: 1px solid var(--ui-border);
}
.settings-form-group:first-of-type {
border-top: 0;
padding-top: 0;
}
.settings-form-group h3,
.settings-rule-section h3 {
font-size: 15px;
line-height: 22px;
}
.settings-form-fields {
display: grid;
gap: 18px;
margin-top: 16px;
}
.settings-field {
display: grid;
gap: 7px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.settings-field > label,
.settings-field > legend,
.settings-account-dialog-field > span,
.settings-switch-label {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.settings-control {
width: 100%;
height: 44px;
min-width: 0;
padding: 0 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
outline: 0;
background: var(--ui-input);
color: var(--ui-text);
color-scheme: inherit;
font: inherit;
}
.settings-control:focus-visible,
.settings-button:focus-visible,
.settings-switch:focus-visible,
.settings-theme-option:focus-visible,
.settings-sidebar-item:focus-visible,
.settings-account-tabs button:focus-visible,
.settings-account-row:focus-visible,
.settings-account-action-button:focus-visible,
.settings-account-picker-row:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.settings-control:disabled,
.settings-button:disabled,
.settings-switch:disabled {
cursor: default;
opacity: 0.5;
}
.settings-textarea {
height: auto;
min-height: 108px;
padding: 10px 12px;
line-height: 1.45;
resize: vertical;
}
.settings-control-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.settings-field-help {
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.settings-switch-field,
.settings-action-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-switch-field > div,
.settings-action-field > div {
display: grid;
min-width: 0;
gap: 3px;
}
.settings-switch {
position: relative;
width: 40px;
height: 20px;
flex: 0 0 40px;
padding: 0;
border: 1px solid var(--ui-border);
border-radius: 10px;
background: var(--ui-input);
}
.settings-switch > span {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--ui-text-muted);
transition: transform 120ms ease, background 120ms ease;
}
.settings-switch.is-on {
border-color: var(--ui-accent);
background: var(--ui-accent);
}
.settings-switch.is-on > span {
background: #FFFFFF;
transform: translateX(20px);
}
.settings-theme-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.settings-theme-option {
display: grid;
gap: 7px;
padding: 8px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-theme-option.is-active {
border-color: var(--ui-accent);
color: var(--ui-text);
}
.settings-theme-preview {
position: relative;
display: block;
height: 42px;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 4px;
}
.settings-theme-preview::after {
position: absolute;
top: 7px;
left: 8px;
width: 58%;
height: 4px;
border-radius: 2px;
background: #9AA1AD;
box-shadow: 0 9px 0 #737B88, 0 18px 0 #737B88;
content: "";
}
.settings-theme-preview.is-light {
background: #F3F4F6;
}
.settings-theme-preview.is-dark {
background: #1C1D20;
}
.settings-theme-preview.is-system {
background: #F3F4F6;
}
.settings-theme-preview.is-system::before {
position: absolute;
inset: 0 0 0 50%;
background: #1C1D20;
content: "";
}
.settings-button {
height: 36px;
padding: 0 13px;
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text-secondary);
font: inherit;
white-space: nowrap;
}
.settings-button-secondary {
background: var(--ui-modal-secondary);
}
.settings-button-primary {
border-color: var(--ui-primary);
background: var(--ui-primary);
color: #181A1F;
}
.settings-button-danger {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
background: transparent;
color: var(--ui-danger);
}
.settings-button:hover:not(:disabled),
.settings-account-tabs button:hover,
.settings-account-action-button:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.settings-button-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
color: #181A1F;
}
.settings-account-workspace {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: 100%;
min-width: 0;
min-height: 0;
gap: 14px;
}
.settings-account-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-account-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--ui-border);
}
.settings-account-tabs button {
min-height: 36px;
padding: 0 12px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ui-text-muted);
font: inherit;
}
.settings-account-tabs button[aria-selected="true"] {
border-bottom-color: var(--ui-accent);
color: var(--ui-text);
}
.settings-account-panel {
display: flex;
width: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.settings-account-panel[hidden] {
display: none;
}
.settings-account-table {
min-height: 280px;
}
.settings-account-table-header {
height: 41px;
overflow: hidden;
}
.settings-account-table-grid,
.settings-account-row {
display: grid;
grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(190px, 1.15fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px;
min-width: 1110px;
align-items: center;
}
.settings-account-table-grid {
height: 41px;
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.settings-account-table-grid > span,
.settings-account-row > span {
min-width: 0;
padding: 0 11px;
}
.settings-account-table-body {
overflow: auto;
}
.settings-account-row {
position: relative;
height: 48px;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 14px;
}
.settings-account-row::before {
position: absolute;
top: 5px;
bottom: 5px;
left: 0;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--ui-accent);
content: "";
opacity: 0;
}
.settings-account-row:hover {
background: var(--ui-hover);
}
.settings-account-row.is-selected {
background: var(--ui-active);
}
.settings-account-row.is-selected::before {
opacity: 1;
}
.settings-account-row.is-disabled {
color: var(--ui-text-muted);
opacity: 0.72;
}
.settings-account-column-enable,
.settings-account-column-actions {
display: grid;
place-items: center;
}
.settings-account-hoster {
display: flex;
align-items: center;
gap: 9px;
}
.settings-account-hoster img {
flex: 0 0 20px;
object-fit: contain;
}
.settings-account-hoster > span {
display: grid;
min-width: 0;
}
.settings-account-hoster strong,
.settings-account-hoster small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-hoster small {
color: var(--ui-text-muted);
font-size: 12px;
font-weight: 400;
}
.settings-account-status-badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 6px;
overflow: hidden;
color: var(--ui-text-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-status-badge::before {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: var(--ui-text-muted);
content: "";
}
.settings-account-status-badge.is-ok::before {
background: var(--ui-primary);
}
.settings-account-status-badge.is-free::before,
.settings-account-status-badge.is-unknown::before {
background: var(--ui-warning);
}
.settings-account-status-badge.is-invalid::before {
background: var(--ui-danger);
}
.settings-account-status-badge.is-disabled::before {
background: var(--ui-text-muted);
}
.settings-account-traffic,
.settings-account-expires,
.settings-account-credential {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-username {
overflow: hidden;
color: var(--ui-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-action-button {
display: grid;
width: 30px;
height: 30px;
padding: 0;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
font-size: 20px;
}
.settings-account-local-actions {
display: flex;
min-height: 52px;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--ui-border);
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-account-local-actions > div {
display: flex;
gap: 8px;
}
.settings-account-table-error .ui-data-table-empty-title,
.settings-account-dialog-error {
color: var(--ui-danger);
}
.settings-account-rules {
display: grid;
width: 100%;
min-width: 0;
gap: 22px;
overflow-y: auto;
}
.settings-rule-section {
display: grid;
gap: 12px;
padding-bottom: 22px;
border-bottom: 1px solid var(--ui-border);
}
.settings-provider-order,
.settings-routing-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.settings-provider-order li,
.settings-routing-list li,
.settings-rotation-event {
display: flex;
min-height: 40px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 10px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
}
.settings-provider-order-actions {
display: flex;
gap: 4px;
}
.settings-provider-order-actions button {
width: 30px;
height: 28px;
border: 1px solid var(--ui-border);
border-radius: 5px;
background: var(--ui-modal-secondary);
color: var(--ui-text-secondary);
}
.settings-rule-toggle {
display: flex;
align-items: center;
gap: 9px;
color: var(--ui-text-secondary);
font-size: 13px;
}
.settings-rule-empty {
color: var(--ui-text-muted);
font-size: 13px;
}
.settings-rotation-event {
align-items: flex-start;
flex-direction: column;
}
.settings-rotation-event span {
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-account-dialog-body {
display: grid;
gap: 16px;
min-height: 0;
}
.settings-account-picker-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 8px;
}
.settings-account-picker {
display: grid;
max-height: 430px;
min-height: 0;
gap: 6px;
overflow-y: auto;
}
.settings-account-picker-entry {
display: grid;
gap: 10px;
}
.settings-account-picker-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 12px;
width: 100%;
min-height: 52px;
padding: 8px 10px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-account-picker-row.is-selected {
border-color: var(--ui-accent);
background: var(--ui-active);
}
.settings-account-picker-row > span {
display: grid;
min-width: 0;
gap: 2px;
}
.settings-account-picker-row small {
overflow: hidden;
color: var(--ui-text-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-picker-empty {
padding: 24px;
color: var(--ui-text-muted);
text-align: center;
}
.settings-account-dialog-fields {
display: grid;
gap: 14px;
padding: 2px 10px 14px;
}
.settings-account-dialog-field {
display: grid;
gap: 7px;
}
.settings-account-dialog-textarea {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.settings-account-dialog-error {
margin: 0;
font-size: 13px;
}
.settings-account-dialog-actions {
justify-content: flex-start;
}
.settings-account-dialog-action-spacer {
flex: 1 1 auto;
}
.settings-account-edit-identity {
display: grid;
gap: 4px;
padding-bottom: 14px;
border-bottom: 1px solid var(--ui-border);
}
.settings-account-edit-identity span {
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-account-edit-identity strong {
color: var(--ui-text);
overflow-wrap: anywhere;
}
.settings-account-edit-enabled {
min-height: 30px;
}
@media (max-width: 1366px) {
.settings-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.settings-sidebar {
overflow: hidden;
}
.settings-content {
padding: 20px;
}
.settings-account-table-grid,
.settings-account-row {
grid-template-columns: 40px 160px 140px 180px 180px 120px 140px 42px;
min-width: 1002px;
}
}
@media (max-width: 1120px) {
.settings-content {
padding: 16px;
}
.settings-theme-options,
.settings-account-picker-controls,
.settings-account-picker-row {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,226 @@
import type { ReactElement, ReactNode } from "react";
import type {
StatisticsMetric,
StatisticsProviderScope,
StatisticsRange,
StatisticsViewModel
} from "./statistics-model";
import "./statistics.css";
export interface StatisticsViewActions {
onRangeChange: (range: StatisticsRange) => void;
onResetSession: () => void;
onResetAll: () => void;
onResetErrors: () => void;
}
export interface StatisticsViewProps {
model: StatisticsViewModel;
actions: StatisticsViewActions;
chart: ReactNode;
}
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
{ id: "session", label: "Sitzung" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Sieben Tage" },
{ id: "month", label: "30 Tage" },
{ id: "all", label: "Gesamt" }
];
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
function formatBytes(bytes: number): string {
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
if (safe < 1024) {
return `${Math.round(safe)} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = safe / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatMetric(metric: StatisticsMetric, kind: "bytes" | "count" | "percent" | "speed"): string {
if (!metric.available || metric.value === null) {
return "";
}
if (kind === "bytes") {
return formatBytes(metric.value);
}
if (kind === "speed") {
return `${formatBytes(metric.value)}/s`;
}
if (kind === "percent") {
return `${numberFormatter.format(metric.value)} %`;
}
return numberFormatter.format(metric.value);
}
function providerScopeLabel(scope: StatisticsProviderScope | null): string {
if (scope === "current-queue") {
return "Aktuelle Queue";
}
if (scope === "today") {
return "Heute";
}
if (scope === "all") {
return "Gesamt";
}
return "Nicht verfügbar";
}
function emptyProviderMessage(model: StatisticsViewModel): string {
if (model.coverage === "unavailable") {
return model.message;
}
if (model.providerScope === "today") {
return "Heute wurden noch keine Providerbytes erfasst.";
}
if (model.providerScope === "all") {
return "Noch keine gespeicherten Providerbytes vorhanden.";
}
return "In der aktuellen Queue sind noch keine Providerwerte vorhanden.";
}
function StatisticsMetricCard({
label,
metric,
kind
}: {
label: string;
metric: StatisticsMetric;
kind: "bytes" | "count" | "percent" | "speed";
}): ReactElement {
return (
<article className={`statistics-kpi${metric.tone === "danger" ? " statistics-kpi-danger" : ""}`}>
<span className="statistics-kpi-label">{label}</span>
<strong className="statistics-kpi-value">{formatMetric(metric, kind)}</strong>
<span className="statistics-kpi-source">{metric.sourceLabel}</span>
</article>
);
}
export function StatisticsSidebar({ model, actions }: Pick<StatisticsViewProps, "model" | "actions">): ReactElement {
return (
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
<strong className="statistics-sidebar-heading">Zeitraum</strong>
<div className="statistics-range-list">
{rangeItems.map((item) => (
<button
aria-current={model.range === item.id ? "page" : undefined}
className={`statistics-range${model.range === item.id ? " statistics-range-active" : ""}`}
key={item.id}
onClick={() => actions.onRangeChange(item.id)}
type="button"
>{item.label}</button>
))}
</div>
<p className="statistics-sidebar-message">{model.message}</p>
</nav>
);
}
export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "model">): ReactElement | null {
const metrics = model.metrics;
const rows = [
metrics.downloadedBytes.available ? `Daten: ${formatMetric(metrics.downloadedBytes, "bytes")}` : null,
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
model.providerScope ? `Provider: ${model.providers.length}` : null
].filter((value): value is string => value !== null);
if (rows.length === 0) {
return null;
}
return (
<div className="statistics-sidebar-status">
{rows.map((row) => <span key={row}>{row}</span>)}
</div>
);
}
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<section aria-label="Statistik-Dashboard" className="statistics-content">
<header className="statistics-heading">
<div>
<h2>Statistiken</h2>
<p>{model.message}</p>
</div>
<div aria-label="Statistiken zurücksetzen" className="statistics-reset-actions">
<button className="statistics-reset" onClick={actions.onResetSession} type="button">Sitzung zurücksetzen</button>
<button className="statistics-reset" onClick={actions.onResetAll} type="button">Gesamt zurücksetzen</button>
<button
className="statistics-reset statistics-reset-danger"
disabled={!model.errorResetAvailable}
onClick={actions.onResetErrors}
type="button"
>Fehler zurücksetzen</button>
</div>
</header>
<div className="statistics-kpis" data-visual-region="statistics-kpis">
<StatisticsMetricCard kind="bytes" label="Datenmenge" metric={model.metrics.downloadedBytes} />
<StatisticsMetricCard kind="count" label="Dateien" metric={model.metrics.files} />
<StatisticsMetricCard kind="percent" label="Erfolgsquote" metric={model.metrics.successRate} />
<StatisticsMetricCard kind="speed" label="Durchschnitt" metric={model.metrics.averageSpeedBps} />
<StatisticsMetricCard kind="count" label="Fehler" metric={model.metrics.errors} />
</div>
<div className="statistics-detail-grid">
<section className="statistics-chart" data-visual-region="statistics-chart">
<div className="statistics-section-heading">
<h3>Bandbreitenverlauf</h3>
<span>Live aus der aktuellen Renderer-Sitzung</span>
</div>
<div className="statistics-chart-canvas">{chart}</div>
</section>
<section className="statistics-providers">
<div className="statistics-section-heading">
<h3>Provider</h3>
<span>{providerScopeLabel(model.providerScope)}</span>
</div>
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
<div className="statistics-provider-header" role="row">
<span role="columnheader">Provider</span>
<span role="columnheader">Daten</span>
<span role="columnheader">Ergebnisse</span>
</div>
<div className="statistics-provider-body" role="rowgroup">
{model.providers.length > 0 ? model.providers.map((provider) => (
<div className="statistics-provider-row" key={provider.id} role="row">
<span className="statistics-provider-name" role="cell">{provider.label}</span>
<span role="cell">{formatBytes(provider.bytes)}</span>
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
{provider.completed === null || provider.failed === null
? ""
: `${provider.completed} fertig · ${provider.failed} Fehler`}
</span>
</div>
)) : (
<div className="statistics-provider-empty" role="row">
<span aria-colspan={3} role="cell">{emptyProviderMessage(model)}</span>
</div>
)}
</div>
</div>
</section>
</div>
</section>
);
}
export function StatisticsView({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<div className="statistics-composed-view">
<StatisticsSidebar actions={actions} model={model} />
<StatisticsContent actions={actions} chart={chart} model={model} />
</div>
);
}
@@ -0,0 +1,280 @@
import { getProviderUsageDayKey } from "../../../shared/provider-daily-limits";
import type { DebridProvider, DownloadItem, DownloadSummary, UiSnapshot } from "../../../shared/types";
export type StatisticsRange = "session" | "today" | "week" | "month" | "all";
export type StatisticsCoverage = "partial" | "unavailable";
export type StatisticsSessionState = "empty" | "idle" | "active" | "paused";
export type StatisticsProviderScope = "current-queue" | "today" | "all";
export type StatisticsMetricTone = "danger";
export interface StatisticsMetric {
value: number | null;
available: boolean;
sourceLabel: string;
tone?: StatisticsMetricTone;
}
export interface StatisticsProviderRow {
id: DebridProvider;
label: string;
bytes: number;
completed: number | null;
failed: number | null;
}
export interface StatisticsMetrics {
downloadedBytes: StatisticsMetric;
files: StatisticsMetric;
successRate: StatisticsMetric;
averageSpeedBps: StatisticsMetric;
errors: StatisticsMetric;
}
export interface StatisticsViewModel {
range: StatisticsRange;
coverage: StatisticsCoverage;
message: string;
sessionState: StatisticsSessionState;
metrics: StatisticsMetrics;
providerScope: StatisticsProviderScope | null;
providers: StatisticsProviderRow[];
errorResetAvailable: boolean;
}
const providerLabels: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
megadebrid: "Mega-Debrid",
"megadebrid-api": "Mega-Debrid API",
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
linksnappy: "LinkSnappy"
};
const historicalUnavailableMessage = "Für diesen Zeitraum werden noch keine historischen Daten gespeichert.";
function normalizeNonNegative(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function normalizeCount(value: number): number {
return Math.floor(normalizeNonNegative(value));
}
function availableMetric(value: number, sourceLabel: string, tone?: StatisticsMetricTone): StatisticsMetric {
return {
value: normalizeNonNegative(value),
available: true,
sourceLabel,
...(tone ? { tone } : {})
};
}
function unavailableMetric(sourceLabel: string): StatisticsMetric {
return {
value: null,
available: false,
sourceLabel
};
}
function sortProviderRows(rows: StatisticsProviderRow[]): StatisticsProviderRow[] {
return rows.sort((left, right) => right.bytes - left.bytes || left.id.localeCompare(right.id));
}
function deriveSessionState(snapshot: UiSnapshot): StatisticsSessionState {
if (snapshot.session.paused) {
return "paused";
}
if (snapshot.session.running) {
return "active";
}
const hasItems = Object.keys(snapshot.session.items).length > 0;
const summary = snapshot.summary;
const hasSummary = Boolean(summary && normalizeCount(summary.success) + normalizeCount(summary.failed) + normalizeCount(summary.cancelled) > 0);
return hasItems || hasSummary ? "idle" : "empty";
}
function countQueueResults(items: DownloadItem[]): { completed: number; failed: number } {
let completed = 0;
let failed = 0;
for (const item of items) {
if (item.status === "completed") {
completed += 1;
} else if (item.status === "failed") {
failed += 1;
}
}
return { completed, failed };
}
function countSummaryResults(summary: DownloadSummary): { completed: number; failed: number } {
return {
completed: normalizeCount(summary.success),
failed: normalizeCount(summary.failed)
};
}
function successRateMetric(completed: number, failed: number, sourceLabel: string): StatisticsMetric {
const terminal = completed + failed;
return terminal > 0
? availableMetric((completed / terminal) * 100, sourceLabel)
: unavailableMetric("Keine abgeschlossenen oder fehlgeschlagenen Ergebnisse");
}
function deriveQueueProviders(items: DownloadItem[]): StatisticsProviderRow[] {
const providers = new Map<DebridProvider, StatisticsProviderRow>();
for (const item of items) {
if (!item.provider) {
continue;
}
const existing = providers.get(item.provider);
const row = existing ?? {
id: item.provider,
label: item.providerLabel?.trim() || providerLabels[item.provider],
bytes: 0,
completed: 0,
failed: 0
};
if (!existing && item.providerLabel?.trim()) {
row.label = item.providerLabel.trim();
}
row.bytes += normalizeNonNegative(item.downloadedBytes);
if (item.status === "completed") {
row.completed = (row.completed ?? 0) + 1;
} else if (item.status === "failed") {
row.failed = (row.failed ?? 0) + 1;
}
providers.set(item.provider, row);
}
return sortProviderRows([...providers.values()]);
}
function deriveUsageProviders(usage: Partial<Record<DebridProvider, number>>): StatisticsProviderRow[] {
const rows: StatisticsProviderRow[] = [];
for (const [id, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) {
const bytes = normalizeNonNegative(rawBytes ?? 0);
if (bytes <= 0 || !providerLabels[id]) {
continue;
}
rows.push({
id,
label: providerLabels[id],
bytes,
completed: null,
failed: null
});
}
return sortProviderRows(rows);
}
function sumProviderBytes(rows: StatisticsProviderRow[]): number {
return rows.reduce((total, row) => total + row.bytes, 0);
}
function buildUnavailableMetrics(): StatisticsMetrics {
return {
downloadedBytes: unavailableMetric(historicalUnavailableMessage),
files: unavailableMetric(historicalUnavailableMessage),
successRate: unavailableMetric(historicalUnavailableMessage),
averageSpeedBps: unavailableMetric(historicalUnavailableMessage),
errors: unavailableMetric(historicalUnavailableMessage)
};
}
export function buildStatisticsViewModel(
snapshot: UiSnapshot,
range: StatisticsRange,
nowMs = Date.now()
): StatisticsViewModel {
const sessionState = deriveSessionState(snapshot);
if (range === "week" || range === "month") {
return {
range,
coverage: "unavailable",
message: historicalUnavailableMessage,
sessionState,
metrics: buildUnavailableMetrics(),
providerScope: null,
providers: [],
errorResetAvailable: false
};
}
if (range === "today") {
const isCurrentDay = snapshot.settings.providerDailyUsageDay === getProviderUsageDayKey(nowMs);
const providers = isCurrentDay ? deriveUsageProviders(snapshot.settings.providerDailyUsageBytes) : [];
return {
range,
coverage: "partial",
message: "Heutige Daten stammen aus den lokalen Provider-Nutzungszählern des aktuellen Kalendertags.",
sessionState,
metrics: {
downloadedBytes: availableMetric(sumProviderBytes(providers), "Provider-Nutzung heute"),
files: unavailableMetric("Dateianzahlen werden nicht tagesweise gespeichert"),
successRate: unavailableMetric("Ergebnisse werden nicht tagesweise gespeichert"),
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert"),
errors: unavailableMetric("Fehler werden nicht tagesweise gespeichert")
},
providerScope: "today",
providers,
errorResetAvailable: false
};
}
if (range === "all") {
const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes);
return {
range,
coverage: "partial",
message: "Gesamtwerte stammen aus dauerhaft gespeicherten Zählern. Ergebnisse und Geschwindigkeiten werden nicht historisch gespeichert.",
sessionState,
metrics: {
downloadedBytes: availableMetric(snapshot.stats.totalDownloadedAllTime, "Gesamtzähler"),
files: availableMetric(snapshot.stats.totalFilesAllTime, "Gesamtzähler"),
successRate: unavailableMetric("Ergebnisse werden nicht dauerhaft gespeichert"),
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert"),
errors: unavailableMetric("Fehler werden nicht dauerhaft gespeichert")
},
providerScope: "all",
providers,
errorResetAvailable: false
};
}
const items = Object.values(snapshot.session.items);
const queueResults = countQueueResults(items);
const runInProgress = snapshot.session.running || snapshot.session.paused;
const useSummary = !runInProgress && snapshot.summary !== null;
const results = useSummary ? countSummaryResults(snapshot.summary as DownloadSummary) : queueResults;
const resultSource = useSummary ? "Letzter beendeter Lauf" : "Aktuelle Queue";
const failed = results.failed;
const summaryAverage = useSummary && results.completed + results.failed > 0
? availableMetric((snapshot.summary as DownloadSummary).averageSpeedBps, "Letzter beendeter Lauf")
: unavailableMetric(runInProgress
? "Während des laufenden Durchgangs nicht als Gesamtwert verfügbar"
: "Kein beendeter Lauf mit Ergebnissen verfügbar");
return {
range,
coverage: "partial",
message: useSummary
? "Sitzungszähler und Ergebnisse des zuletzt beendeten Laufs werden angezeigt."
: "Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.",
sessionState,
metrics: {
downloadedBytes: availableMetric(snapshot.stats.totalDownloaded, "Sitzungszähler"),
files: availableMetric(snapshot.stats.totalFilesSession, "Sitzungszähler"),
successRate: successRateMetric(results.completed, failed, resultSource),
averageSpeedBps: summaryAverage,
errors: availableMetric(failed, resultSource, failed > 0 ? "danger" : undefined)
},
providerScope: "current-queue",
providers: deriveQueueProviders(items),
errorResetAvailable: queueResults.failed > 0
};
}
@@ -0,0 +1,370 @@
.statistics-sidebar,
.statistics-content,
.statistics-sidebar-status {
user-select: none;
}
.statistics-sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.statistics-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
}
.statistics-range-list {
display: grid;
gap: 4px;
}
.statistics-range {
display: flex;
width: 100%;
min-height: 36px;
padding: 8px 10px;
align-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
text-align: left;
}
.statistics-range:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-range-active {
border-color: var(--ui-border);
background: var(--ui-active);
color: var(--ui-text);
}
.statistics-range:focus-visible,
.statistics-reset:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.statistics-sidebar-message {
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-sidebar-status {
display: contents;
}
.statistics-content {
display: grid;
min-width: 0;
min-height: 100%;
align-content: start;
gap: 16px;
padding: 20px 24px 24px;
overflow: auto;
}
.statistics-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.statistics-heading h2,
.statistics-section-heading h3 {
margin: 0;
color: var(--ui-text);
}
.statistics-heading h2 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.statistics-heading p {
max-width: 760px;
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-reset-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.statistics-reset {
min-height: 36px;
padding: 7px 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
}
.statistics-reset:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-reset-danger {
color: var(--ui-danger);
}
.statistics-reset:disabled {
color: var(--ui-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
.statistics-kpis {
display: grid;
min-width: 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.statistics-kpi {
display: flex;
min-width: 0;
min-height: 116px;
padding: 14px 16px;
flex-direction: column;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-kpi-label {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-kpi-value {
min-width: 0;
color: var(--ui-text);
font-size: clamp(20px, 1.6vw, 28px);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.statistics-kpi-danger .statistics-kpi-value {
color: var(--ui-danger);
}
.statistics-kpi-source {
min-height: 32px;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
overflow-wrap: anywhere;
}
.statistics-detail-grid {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 16px;
}
.statistics-chart,
.statistics-providers {
display: flex;
min-width: 0;
min-height: 340px;
padding: 16px;
flex-direction: column;
gap: 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-section-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.statistics-section-heading h3 {
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.statistics-section-heading span {
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
text-align: right;
}
.statistics-chart-canvas {
min-width: 0;
min-height: 280px;
flex: 1 1 auto;
}
.statistics-chart-canvas .bandwidth-chart-container {
height: 100%;
min-height: 280px;
margin: 0;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
}
.statistics-provider-table {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 6px;
}
.statistics-provider-header,
.statistics-provider-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(120px, 1fr) minmax(88px, 0.65fr) minmax(130px, 1fr);
align-items: center;
gap: 12px;
}
.statistics-provider-header {
min-height: 41px;
padding: 0 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-provider-body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
}
.statistics-provider-row {
min-height: 48px;
padding: 8px 12px;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 18px;
}
.statistics-provider-row:last-child {
border-bottom: 0;
}
.statistics-provider-row:hover {
background: var(--ui-hover);
}
.statistics-provider-row > span {
min-width: 0;
overflow-wrap: anywhere;
}
.statistics-provider-name {
color: var(--ui-text);
font-weight: 600;
}
.statistics-provider-errors {
color: var(--ui-danger);
}
.statistics-provider-empty {
display: grid;
min-height: 180px;
padding: 24px;
place-items: center;
color: var(--ui-text-muted);
font-size: 13px;
line-height: 20px;
text-align: center;
}
.statistics-composed-view {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: 270px minmax(0, 1fr);
gap: 8px;
overflow: hidden;
}
@media (max-width: 1500px) {
.statistics-composed-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.statistics-sidebar {
overflow: hidden;
}
.statistics-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.statistics-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 1120px) {
.statistics-content {
padding: 16px;
}
.statistics-heading {
flex-direction: column;
}
.statistics-reset-actions {
justify-content: flex-start;
}
.statistics-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+4 -3
View File
@@ -68,9 +68,10 @@ export const IPC_CHANNELS = {
RETRY_EXTRACTION: "queue:retry-extraction",
EXTRACT_NOW: "queue:extract-now",
RESET_PACKAGE: "queue:reset-package",
GET_HISTORY: "history:get",
CLEAR_HISTORY: "history:clear",
REMOVE_HISTORY_ENTRY: "history:remove-entry",
GET_HISTORY: "history:get",
CLEAR_HISTORY: "history:clear",
REMOVE_HISTORY_ENTRY: "history:remove-entry",
REVEAL_HISTORY_ENTRY: "history:reveal-entry",
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items",
+6 -4
View File
@@ -8,7 +8,8 @@ import type {
DebridProvider,
DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry,
HistoryEntry,
HistoryRevealResult,
PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport,
@@ -89,9 +90,10 @@ export interface ElectronApi {
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>;
revealHistoryEntry: (entryId: string) => Promise<HistoryRevealResult>;
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>;
+15 -4
View File
@@ -524,10 +524,21 @@ export interface HistoryEntry {
urls?: string[];
}
export interface HistoryState {
entries: HistoryEntry[];
maxEntries: number;
}
export interface HistoryState {
entries: HistoryEntry[];
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 {
kind: "error" | "unhandledrejection" | "react";