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
@@ -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;
}
}