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
+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>
);
}