release: publish Multi-Debrid Downloader v2.0.14

Add live English and German localization, queue availability and metadata resolution, responsive package controls, polished navigation and drag interactions, clearer history and account states, and a rebuilt public README. Harden Windows packaging with verified icons and version metadata, archive inspection, and expanded release tests.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:42:49 +02:00
parent 069babfd54
commit a5758aa905
61 changed files with 9117 additions and 6754 deletions
+205 -204
View File
@@ -1,208 +1,209 @@
import type { ChangeEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
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;
}
import { SlidingSelection } from "../../ui/SlidingSelection";
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">
<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>
<SlidingSelection activeKey={model.activeTabId} axis="vertical" 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>
);
}
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} data-sliding-selection-active={tab.id === model.activeTabId} data-sliding-selection-item="true" 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>
))}
</SlidingSelection>
<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>
);
}
+306 -306
View File
@@ -1,309 +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-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;
}
border-color: transparent;
}
.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;
}
}
+168 -35
View File
@@ -1,10 +1,11 @@
import { memo, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
import { memo, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
import type { DownloadItem } from "../../../shared/types";
import {
compactProviderLabels,
extractHoster,
formatAudioStripSummary,
formatDateTime,
formatHosterLabel,
formatSpeedMbps,
humanSize,
providerLabels
@@ -13,24 +14,77 @@ import type { DownloadPackageRow } from "./downloads-model";
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
type HosterLabel = ReturnType<typeof formatHosterLabel>;
function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement {
const [iconFailed, setIconFailed] = useState(false);
if (label.iconSrc && !iconFailed) return <img alt="" className="downloads-hoster-icon" data-hoster={label.title.toLowerCase()} onError={() => setIconFailed(true)} src={label.iconSrc} />;
return <span>{label.compact}</span>;
}
function HosterLabels({ labels }: { labels: HosterLabel[] }): ReactElement {
return (
<span className="downloads-cell downloads-hoster-cell" title={labels.map((label) => label.title).join(", ")}>
{labels.map((label) => <HosterLabelContent key={label.title} label={label} />)}
</span>
);
}
function downloadGridTemplate(gridTemplate: string): string {
return `${DOWNLOAD_SELECTION_COLUMN_WIDTH} ${gridTemplate} ${DOWNLOAD_ACTION_COLUMN_WIDTH}`;
}
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" }
name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
progress: { label: "Fortschritt", width: "minmax(105px, 0.85fr)", sortable: "progress" },
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
status: { label: "Status", width: "minmax(90px, 0.85fr)" },
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
};
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
const total = items.length;
const online = items.filter((item) => item.onlineStatus === "online").length;
const offline = items.filter((item) => item.onlineStatus === "offline").length;
if (total > 0 && online === total) return { online, total, state: "online" };
if (total > 0 && offline === total) return { online, total, state: "offline" };
if (total > 0 && online + offline === total) return { online, total, state: "partial" };
return { online, total, state: "checking" };
}
function Availability({ online, total, state, text }: { online: number; total: number; state: AvailabilityState; text?: string }): ReactElement {
const symbol = state === "online" ? "✓" : state === "offline" ? "×" : "●";
const label = text ?? `${online}/${total} online`;
if (text) {
return <span aria-label={label} className={`downloads-cell downloads-availability is-${state}`} title={label}><span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span><span className="downloads-availability-label">{text}</span></span>;
}
return (
<span aria-label={label} className={`downloads-cell downloads-availability has-counts is-${state}`} title={label}>
<span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span>
<span className="downloads-availability-count is-online-count">{online}</span>
<span aria-hidden="true" className="downloads-availability-separator">/</span>
<span className="downloads-availability-count is-total-count">{total}</span>
<span className="downloads-availability-label">online</span>
</span>
);
}
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;
@@ -41,11 +95,10 @@ export interface DownloadsTableActions {
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;
onColumnPointerDown: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnPointerMove: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnPointerUp: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnPointerCancel: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnContextMenu: (column: string, x: number, y: number) => void;
onSortColumn: (column: DownloadSortColumn) => void;
}
@@ -85,12 +138,18 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
}
if (column === "hoster") {
const hoster = extractHoster(item.url);
return <span className="downloads-cell" title={hoster}>{hoster}</span>;
const label = formatHosterLabel(hoster);
return <HosterLabels labels={[label]} />;
}
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 === "availability") {
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft";
return <Availability online={state === "online" ? 1 : 0} total={1} state={state} text={text} />;
}
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
return null;
}
@@ -110,7 +169,7 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
className={`downloads-item-row${selected ? " is-selected" : ""}`}
data-download-row-id={item.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
onClick={(event) => {
event.stopPropagation();
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
@@ -126,8 +185,8 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
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-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(item.id, true, event.shiftKey); }} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} 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>
);
@@ -163,6 +222,71 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
interface PackageItemsTransitionProps {
actions: DownloadsTableActions;
collapsed: boolean;
columnOrder: readonly string[];
gridTemplate: string;
items: DownloadItem[];
selectedIds: ReadonlySet<string>;
sessionRunning: boolean;
}
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
const [renderItems, setRenderItems] = useState(!collapsed);
const animationRef = useRef<Animation | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const initialRenderRef = useRef(true);
useRendererLayoutEffect(() => {
if (initialRenderRef.current) {
initialRenderRef.current = false;
return;
}
if (!renderItems) {
if (!collapsed) setRenderItems(true);
return;
}
const container = containerRef.current;
const inner = innerRef.current;
if (!container || !inner) return;
animationRef.current?.cancel();
const targetHeight = inner.scrollHeight;
const animation = collapsed
? container.animate([{ height: `${targetHeight}px`, opacity: 1 }, { height: "0px", opacity: 0 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" })
: container.animate([{ height: "0px", opacity: 0 }, { height: `${targetHeight}px`, opacity: 1 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" });
animationRef.current = animation;
animation.onfinish = () => {
if (animationRef.current !== animation) return;
animationRef.current = null;
if (collapsed) {
setRenderItems(false);
} else {
animation.cancel();
}
};
return () => {
animation.onfinish = null;
animation.cancel();
if (animationRef.current === animation) animationRef.current = null;
};
}, [collapsed, renderItems]);
if (!renderItems) return null;
return (
<div
aria-hidden={collapsed}
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
ref={containerRef}
>
<div className="downloads-package-items-inner" ref={innerRef}>
{items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
</div>
</div>
);
}
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
let done = 0;
let failed = 0;
@@ -204,7 +328,6 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
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") {
@@ -228,8 +351,8 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
}
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>;
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
return <HosterLabels labels={labels} />;
}
if (column === "account") {
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
@@ -241,6 +364,10 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
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 === "availability") {
const availability = getAvailabilitySummary(row.items);
return <Availability {...availability} />;
}
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
return null;
}
@@ -272,7 +399,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
};
return (
<article
className={`package-card downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
className={`downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
data-download-package-id={entry.id}
draggable={draggable}
onContextMenu={(event) => {
@@ -289,7 +416,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
className="downloads-package-row"
data-download-row-id={entry.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
@@ -302,11 +429,11 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
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-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(entry.id, true, event.shiftKey); }} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} 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} />)}
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
</article>
);
}
@@ -345,7 +472,7 @@ export interface DownloadsTableHeaderProps {
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` }}>
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
<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];
@@ -353,14 +480,20 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
return (
<div
className="downloads-column-header"
draggable
data-download-column={column}
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)}
onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)}
onPointerDown={(event) => {
if (event.button !== 0 || !event.isPrimary) return;
event.currentTarget.setPointerCapture(event.pointerId);
actions.onColumnPointerDown(column, event);
}}
onPointerMove={(event) => actions.onColumnPointerMove(column, event)}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
actions.onColumnPointerUp(column, event);
}}
role="columnheader"
>
{definition.sortable
+185 -184
View File
@@ -1,197 +1,198 @@
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;
import { RollingMetricValue } from "../../ui/RollingMetricValue";
import { SlidingSelection } from "../../ui/SlidingSelection";
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;
sessionBytes: number;
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" }
];
totalBytes: number;
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>
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
<div className="downloads-mode-title">Pakete</div>
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
</SlidingSelection>
<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>
);
}
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
</aside>
);
}
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, "");
const eta = model.status.eta.replace(/^ETA:\s*/i, "");
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]
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: String(model.status.packages) },
{ label: "Links", metric: "links", numericValue: model.status.links, value: String(model.status.links) },
{ label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session },
{ label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total },
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: String(model.status.hosters) }
];
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>
);
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</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>
);
}
+129
View File
@@ -0,0 +1,129 @@
export interface ColumnDragMeasurement {
id: string;
left: number;
width: number;
}
export interface ColumnDragPreview {
order: string[];
offsets: Record<string, number>;
settleOffsets: Record<string, number>;
}
export interface DownloadColumnDragSession {
active: boolean;
draggedId: string;
measurements: ColumnDragMeasurement[];
pointerId: number;
preview: ColumnDragPreview;
root: HTMLElement;
startX: number;
}
export function calculateColumnDragPreview(columns: readonly ColumnDragMeasurement[], draggedId: string, deltaX: number): ColumnDragPreview {
const dragged = columns.find((column) => column.id === draggedId);
if (!dragged) {
const order = columns.map((column) => column.id);
const offsets = Object.fromEntries(order.map((id) => [id, 0]));
return { order, offsets, settleOffsets: { ...offsets } };
}
const draggedCenter = dragged.left + dragged.width / 2 + deltaX;
const remaining = columns.filter((column) => column.id !== draggedId);
let targetIndex = remaining.findIndex((column) => draggedCenter < column.left + column.width / 2);
if (targetIndex < 0) targetIndex = remaining.length;
const order = remaining.map((column) => column.id);
order.splice(targetIndex, 0, draggedId);
const originalById = new Map(columns.map((column) => [column.id, column]));
const targetLeftById = new Map<string, number>();
let nextLeft = columns[0]?.left ?? 0;
for (const id of order) {
const column = originalById.get(id);
if (!column) continue;
targetLeftById.set(id, nextLeft);
nextLeft += column.width;
}
const settleOffsets = Object.fromEntries(columns.map((column) => [column.id, (targetLeftById.get(column.id) ?? column.left) - column.left]));
const offsets = { ...settleOffsets, [draggedId]: deltaX };
return { order, offsets, settleOffsets };
}
function setColumnOffsets(root: HTMLElement, offsets: Record<string, number>, excludedId?: string): void {
for (const [id, offset] of Object.entries(offsets)) {
if (id === excludedId) continue;
root.style.setProperty(`--downloads-column-drag-${id}`, `${offset}px`);
}
}
function setDraggedColumn(root: HTMLElement, draggedId: string, dragging: boolean): void {
if (dragging && root.dataset.columnDragging !== draggedId) {
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
root.querySelectorAll<HTMLElement>(`[data-download-column="${draggedId}"]`).forEach((element) => { element.dataset.columnDragging = "true"; });
root.dataset.columnDragging = draggedId;
} else if (!dragging && root.dataset.columnDragging) {
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
delete root.dataset.columnDragging;
}
}
export function beginDownloadColumnDrag(element: HTMLElement, draggedId: string, pointerId: number, clientX: number): DownloadColumnDragSession | null {
const root = element.closest<HTMLElement>(".downloads-table");
const header = element.closest<HTMLElement>(".downloads-table-header");
if (!root || !header) return null;
const measurements = Array.from(header.querySelectorAll<HTMLElement>(".downloads-column-header[data-download-column]")).map((column) => {
const rect = column.getBoundingClientRect();
return { id: column.dataset.downloadColumn ?? "", left: rect.left, width: rect.width };
}).filter((column) => column.id && column.width > 0);
if (!measurements.some((column) => column.id === draggedId)) return null;
return {
active: false,
draggedId,
measurements,
pointerId,
preview: calculateColumnDragPreview(measurements, draggedId, 0),
root,
startX: clientX
};
}
export function updateDownloadColumnDrag(session: DownloadColumnDragSession, clientX: number): boolean {
const deltaX = clientX - session.startX;
if (!session.active && Math.abs(deltaX) < 5) return false;
if (!session.active) {
session.active = true;
session.root.classList.add("is-column-drag-active");
setDraggedColumn(session.root, session.draggedId, true);
}
const nextPreview = calculateColumnDragPreview(session.measurements, session.draggedId, deltaX);
session.root.style.setProperty("--downloads-active-drag-x", `${deltaX}px`);
if (nextPreview.order.join("|") !== session.preview.order.join("|")) {
setColumnOffsets(session.root, nextPreview.offsets, session.draggedId);
}
session.preview = nextPreview;
return true;
}
export function settleDownloadColumnDrag(session: DownloadColumnDragSession, cancelled: boolean): string[] | null {
if (!session.active) return null;
const originalOrder = session.measurements.map((column) => column.id);
const order = cancelled ? originalOrder : session.preview.order;
const offsets = cancelled
? Object.fromEntries(originalOrder.map((id) => [id, 0]))
: session.preview.settleOffsets;
setColumnOffsets(session.root, offsets);
session.root.style.removeProperty("--downloads-active-drag-x");
setDraggedColumn(session.root, session.draggedId, false);
session.root.classList.add("is-column-drag-settling");
return order;
}
export function clearDownloadColumnDrag(session: DownloadColumnDragSession): void {
session.root.classList.remove("is-column-drag-active", "is-column-drag-settling");
session.root.style.removeProperty("--downloads-active-drag-x");
session.measurements.forEach((measurement) => session.root.style.removeProperty(`--downloads-column-drag-${measurement.id}`));
session.root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
delete session.root.dataset.columnDragging;
}
+188 -180
View File
@@ -1,186 +1,194 @@
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";
}
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;
}
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);
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
let total = 0;
for (const item of items) {
total += item.totalBytes || item.downloadedBytes || 0;
}
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
};
return total;
}
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
};
}
+301 -30
View File
@@ -34,43 +34,51 @@
flex: 1;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
padding: 14px 12px;
padding: 14px 12px 6px;
}
.downloads-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 2px;
.downloads-mode-title {
display: flex;
min-height: 36px;
align-items: center;
padding: 0 10px;
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text);
background: var(--ui-active);
font-weight: 700;
}
.downloads-mode-switch button,
.downloads-sidebar nav button,
.downloads-sidebar-actions button {
min-height: 36px;
border: 0;
border: 1px solid transparent;
border-radius: 4px;
color: var(--ui-text);
background: transparent;
text-align: left;
}
.downloads-mode-switch button {
text-align: center;
.downloads-sidebar nav button:not(.is-active):hover,
.downloads-sidebar-actions button:hover:not(:disabled),
.downloads-clipboard-toggle:hover {
border-color: var(--ui-border);
background: var(--ui-hover);
}
.downloads-mode-switch button.is-active,
.downloads-sidebar nav button.is-active {
color: #ffffff;
background: var(--ui-accent);
background: transparent;
}
.downloads-sidebar nav {
display: grid;
gap: 3px;
--ui-sliding-selection-background: var(--ui-accent);
--ui-sliding-selection-border: transparent;
--ui-sliding-selection-radius: 4px;
}
.downloads-sidebar nav button {
@@ -103,15 +111,31 @@
.downloads-sidebar-actions {
display: grid;
gap: 5px;
margin-top: auto;
}
.downloads-sidebar-actions label {
.downloads-sidebar-actions button {
border: 1px solid var(--ui-border);
background: var(--ui-input);
padding: 0 10px;
cursor: pointer;
}
.downloads-sidebar-actions button:disabled {
opacity: 0.45;
cursor: default;
}
.downloads-clipboard-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-top: auto;
min-height: 36px;
padding: 0 8px;
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-input);
white-space: nowrap;
}
.downloads-sidebar-status {
@@ -135,6 +159,23 @@
text-align: right;
}
.downloads-rolling-value {
display: inline-grid;
height: 1.35em;
min-width: 0;
align-items: center;
justify-items: end;
overflow: hidden;
font-variant-numeric: tabular-nums;
line-height: 1.35;
}
.downloads-rolling-value-layer {
grid-area: 1 / 1;
min-width: 0;
white-space: nowrap;
}
.downloads-main-column {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
@@ -211,6 +252,14 @@
min-width: 0;
overflow-x: auto;
overflow-y: auto;
scrollbar-gutter: stable;
}
.downloads-table-header,
.downloads-table-body {
box-sizing: border-box;
width: 100%;
min-width: 1191px;
}
.downloads-table-header {
@@ -220,9 +269,8 @@
position: sticky;
top: 0;
z-index: 2;
min-width: max-content;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-muted);
color: var(--ui-text);
background: var(--ui-table-header);
font-size: 11px;
font-weight: 700;
@@ -230,54 +278,113 @@
}
.downloads-table-body {
min-width: max-content;
overflow: visible;
contain: layout style;
}
.downloads-package-card {
min-width: max-content;
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 0;
border-bottom: 1px solid var(--ui-border);
border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 72%, transparent);
border-radius: 0;
padding: 0;
background: var(--ui-canvas);
background: transparent;
box-shadow: none;
}
.downloads-package-card.is-disabled {
opacity: 0.58;
}
.downloads-package-items {
height: auto;
overflow: hidden;
opacity: 1;
}
.downloads-package-items.is-collapsed {
height: 0;
opacity: 0;
pointer-events: none;
}
.downloads-package-items-inner {
min-height: 0;
}
.downloads-view :where(button, input, select):focus-visible {
outline: 1px solid #7B8492;
outline-offset: 1px;
}
.downloads-item-row,
.downloads-package-row {
display: grid;
align-items: center;
height: 48px;
min-width: max-content;
height: 40px;
box-sizing: border-box;
width: 100%;
min-width: 0;
color: var(--ui-text);
background: var(--ui-canvas);
background: transparent;
border-color: var(--ui-border);
contain: layout paint style;
content-visibility: auto;
contain-intrinsic-size: 40px;
}
.downloads-item-row {
height: 38px;
contain-intrinsic-size: 38px;
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);
background: color-mix(in srgb, var(--ui-success) 14%, var(--ui-canvas));
box-shadow: inset 3px 0 0 var(--ui-success);
}
.downloads-package-row:hover,
.downloads-item-row:hover {
background: var(--ui-hover);
}
.downloads-item-row.is-selected:hover,
.downloads-package-card.is-selected > .downloads-package-row:hover {
background: color-mix(in srgb, var(--ui-success) 18%, var(--ui-canvas));
}
.downloads-cell-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-width: 0;
}
.downloads-cell-slot > .downloads-cell {
justify-content: center;
width: 100%;
height: 100%;
text-align: center;
}
.downloads-cell-slot[data-download-column="name"] > .downloads-name-cell {
justify-content: flex-start;
text-align: left;
}
.downloads-cell,
.downloads-name-cell {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
padding: 0 9px;
padding: 0 7px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -298,20 +405,117 @@
min-width: 0;
}
.downloads-action-cell button,
.downloads-collapse-button {
.downloads-hoster-cell {
gap: 6px;
}
.downloads-hoster-icon {
display: block;
flex: 0 0 18px;
width: 18px;
height: 18px;
object-fit: contain;
}
.downloads-hoster-icon[data-hoster="rapidgator"] {
transform: translateY(-4px) scale(2);
}
.downloads-selection-cell input[type="checkbox"] {
box-sizing: border-box;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--ui-success);
cursor: pointer;
}
.downloads-action-cell button {
box-sizing: border-box;
width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
}
.downloads-collapse-button {
box-sizing: border-box;
flex: 0 0 30px;
width: 30px;
min-width: 30px;
max-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.downloads-name-cell > input[type="checkbox"] {
box-sizing: border-box;
flex: 0 0 14px;
width: 14px;
height: 14px;
margin: 0;
}
.downloads-column-header {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 0 9px;
overflow: hidden;
white-space: nowrap;
text-align: center;
text-overflow: ellipsis;
touch-action: none;
cursor: grab;
}
.downloads-table.is-column-drag-active [data-download-column] {
position: relative;
transform: translate3d(var(--downloads-column-drag-x, 0px), 0, 0);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.downloads-table [data-download-column="name"] { --downloads-column-drag-x: var(--downloads-column-drag-name, 0px); }
.downloads-table [data-download-column="size"] { --downloads-column-drag-x: var(--downloads-column-drag-size, 0px); }
.downloads-table [data-download-column="progress"] { --downloads-column-drag-x: var(--downloads-column-drag-progress, 0px); }
.downloads-table [data-download-column="hoster"] { --downloads-column-drag-x: var(--downloads-column-drag-hoster, 0px); }
.downloads-table [data-download-column="account"] { --downloads-column-drag-x: var(--downloads-column-drag-account, 0px); }
.downloads-table [data-download-column="prio"] { --downloads-column-drag-x: var(--downloads-column-drag-prio, 0px); }
.downloads-table [data-download-column="status"] { --downloads-column-drag-x: var(--downloads-column-drag-status, 0px); }
.downloads-table [data-download-column="speed"] { --downloads-column-drag-x: var(--downloads-column-drag-speed, 0px); }
.downloads-table [data-download-column="availability"] { --downloads-column-drag-x: var(--downloads-column-drag-availability, 0px); }
.downloads-table [data-download-column="added"] { --downloads-column-drag-x: var(--downloads-column-drag-added, 0px); }
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
--downloads-column-drag-x: var(--downloads-active-drag-x, 0px);
z-index: 4;
transition: none;
cursor: grabbing;
}
.downloads-table.is-column-drag-active .downloads-column-header[data-column-dragging="true"] {
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent), inset -2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent);
}
.downloads-table.is-column-drag-settling [data-download-column] {
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
.downloads-table.is-column-drag-active [data-download-column],
.downloads-table.is-column-drag-settling [data-download-column] {
transition-duration: 220ms !important;
}
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
transition-duration: 0s !important;
}
}
.downloads-column-header button {
@@ -319,10 +523,19 @@
border: 0;
padding: 0;
background: transparent;
text-align: left;
text-align: center;
text-transform: inherit;
}
.downloads-column-header[data-download-column="name"] {
justify-content: flex-start;
text-align: left;
}
.downloads-column-header[data-download-column="name"] button {
text-align: left;
}
.downloads-link-state {
flex: 0 0 8px;
width: 8px;
@@ -343,11 +556,65 @@
background: var(--ui-warning);
}
.downloads-availability {
display: grid;
grid-template-columns: 16px 7ch;
gap: 0;
justify-content: center;
font-weight: 700;
}
.downloads-availability.has-counts {
display: grid;
grid-template-columns: 16px 4ch 1ch 4ch auto;
font-variant-numeric: tabular-nums;
}
.downloads-availability-symbol {
width: 16px;
font-size: 15px;
line-height: 1;
text-align: center;
}
.downloads-availability-count.is-online-count {
text-align: right;
}
.downloads-availability-count.is-total-count {
text-align: left;
}
.downloads-availability-separator {
text-align: center;
}
.downloads-availability-label {
padding-left: 4px;
text-align: left;
}
.downloads-availability.is-online {
color: var(--ui-success);
}
.downloads-availability.is-partial {
color: var(--ui-warning);
}
.downloads-availability.is-offline {
color: var(--ui-danger);
}
.downloads-availability.is-checking {
color: var(--ui-text-muted);
}
.downloads-meter {
position: relative;
display: block;
width: 100%;
height: 24px;
height: 20px;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 4px;
@@ -357,7 +624,7 @@
.downloads-meter > span {
position: absolute;
inset: 0 auto 0 0;
background: color-mix(in srgb, var(--ui-accent) 58%, transparent);
background: var(--ui-success);
}
.downloads-meter > b {
@@ -371,6 +638,10 @@
font-weight: 600;
}
.downloads-item-row .downloads-meter > b {
color: var(--ui-text);
}
.downloads-empty-state,
.downloads-table-message {
display: flex;
+232 -228
View File
@@ -1,239 +1,243 @@
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableFooter,
DataTableHeader
} from "../../ui/DataTable";
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>
);
}
import { SlidingSelection } from "../../ui/SlidingSelection";
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
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
<strong className="history-sidebar-heading">Verlauf</strong>
<SlidingSelection activeKey={model.filter} axis="vertical" 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
data-sliding-selection-active={model.filter === item.id}
data-sliding-selection-item="true"
key={item.id}
onClick={() => actions.onFilterChange(item.id)}
type="button"
>
<span>{item.label}</span>
<span>{model.counts[item.id]}</span>
</button>
))}
</SlidingSelection>
<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) => {
event.stopPropagation();
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>
);
}
></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>
);
}
+363 -348
View File
@@ -1,366 +1,381 @@
.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-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);
background: transparent;
border-color: transparent;
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-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;
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-header-row > span {
text-align: center;
}
.history-table > .history-table-body {
overflow: visible;
.history-table-header-row > span:nth-child(2) {
text-align: left;
}
.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-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 {
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 {
background: var(--ui-input);
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;
border-radius: 5px;
color: var(--ui-text);
height: 30px;
min-height: 30px;
padding: 0;
width: 30px;
}
.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-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;
}
}
.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;
}
}
File diff suppressed because it is too large Load Diff
+97 -94
View File
@@ -1,99 +1,102 @@
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 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;
}
import { SlidingSelection } from "../../ui/SlidingSelection";
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
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
<div className="settings-sidebar-heading">
<strong>Einstellungen</strong>
</div>
<SlidingSelection activeKey={model.section} axis="vertical" 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>
);
}
data-sliding-selection-active={model.section === section.id}
data-sliding-selection-item="true"
key={section.id}
onClick={() => actions.onSectionChange(section.id)}
type="button"
>{section.label}</button>
))}
</SlidingSelection>
</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>
);
}
@@ -408,6 +408,22 @@ export function buildSettingsFormViewModel({
title: "Allgemein",
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
groups: [
{
id: "general-language",
title: "Sprache",
fields: [
{
id: "language",
kind: "select",
label: "Sprache",
value: settings.language,
options: [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" }
]
}
]
},
{
id: "general-storage",
title: "Speicherort",
+9 -5
View File
@@ -53,8 +53,8 @@
}
.settings-sidebar-item.is-active {
border-color: var(--ui-border);
background: var(--ui-active);
border-color: transparent;
background: transparent;
color: var(--ui-text);
}
@@ -439,7 +439,7 @@
}
.settings-account-tabs button[aria-selected="true"] {
border-bottom-color: var(--ui-accent);
border-bottom-color: transparent;
color: var(--ui-text);
}
@@ -474,13 +474,17 @@
.settings-account-table-grid {
height: 41px;
color: var(--ui-text-muted);
color: var(--ui-text);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.settings-form-group:has(#update\:check) .settings-action-field {
justify-content: flex-end;
}
.settings-account-table-grid > span,
.settings-account-row > span {
min-width: 0;
@@ -604,7 +608,7 @@
.settings-account-traffic,
.settings-account-expires,
.settings-account-credential {
color: var(--ui-text-muted);
color: var(--ui-text);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
+223 -220
View File
@@ -1,226 +1,229 @@
import type { ReactElement, ReactNode } from "react";
import type { ReactElement, ReactNode } from "react";
import type {
StatisticsMetric,
StatisticsProviderScope,
StatisticsRange,
StatisticsViewModel
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>
);
}
import { SlidingSelection } from "../../ui/SlidingSelection";
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
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
<strong className="statistics-sidebar-heading">Zeitraum</strong>
<SlidingSelection activeKey={model.range} axis="vertical" 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>
);
}
data-sliding-selection-active={model.range === item.id}
data-sliding-selection-item="true"
key={item.id}
onClick={() => actions.onRangeChange(item.id)}
type="button"
>{item.label}</button>
))}
</SlidingSelection>
<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>
);
}
+367 -367
View File
@@ -1,370 +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-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);
border-color: transparent;
background: transparent;
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));
}
}
.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));
}
}