feat: deliver the redesigned desktop workspace
Rebuild downloads, link collection, settings, history, and statistics around a responsive desktop shell with compact account and queue tables, contextual navigation, persistent update affordances, unified overlays, and accessible keyboard interactions. Add safe history-folder reveal IPC, responsive 2560/1920/1366/1120 coverage, deterministic visual fixtures, focused component regressions, and release-tree exclusions for internal working files. Bump the public application version to 2.0.13.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import type { ChangeEvent, ReactElement } from "react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import type { CollectorViewModel } from "./collector-model";
|
||||
import "./collector.css";
|
||||
|
||||
export interface CollectorViewActions {
|
||||
onTabSelect: (tabId: string) => void;
|
||||
onTabAdd: () => void;
|
||||
onTabRemove: (tabId: string) => void;
|
||||
onOpenInput: () => void;
|
||||
onImportDlc: () => void;
|
||||
onImportFile: () => void;
|
||||
onExportQueue: () => void;
|
||||
onSubmit: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelectionChange: (rowId: string) => void;
|
||||
onRemoveSelected: () => void;
|
||||
}
|
||||
|
||||
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
|
||||
|
||||
export interface CollectorViewProps {
|
||||
model: CollectorViewModel;
|
||||
actions: CollectorViewActions;
|
||||
region?: CollectorViewRegion;
|
||||
}
|
||||
|
||||
export interface CollectorInputDialogProps {
|
||||
open: boolean;
|
||||
tabName: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
return (
|
||||
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
|
||||
<div className="collector-sidebar-heading">
|
||||
<strong>Sammlungen</strong>
|
||||
<span>{model.tabs.length}</span>
|
||||
</div>
|
||||
<div className="collector-sidebar-list">
|
||||
{model.tabs.map((tab) => (
|
||||
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} key={tab.id}>
|
||||
<button
|
||||
aria-current={tab.id === model.activeTabId ? "page" : undefined}
|
||||
className="collector-sidebar-select"
|
||||
onClick={() => actions.onTabSelect(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{tab.name}</span>
|
||||
<span className="collector-sidebar-count">{tab.linkCount}</span>
|
||||
</button>
|
||||
{model.tabs.length > 1 ? (
|
||||
<button
|
||||
aria-label={`${tab.name} entfernen`}
|
||||
className="collector-sidebar-remove"
|
||||
onClick={() => actions.onTabRemove(tab.id)}
|
||||
type="button"
|
||||
>×</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
return (
|
||||
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
||||
<ToolbarGroup label="Links erfassen">
|
||||
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup label="Sammlung verarbeiten">
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
|
||||
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSearch
|
||||
label="Links durchsuchen"
|
||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||
placeholder="Links durchsuchen"
|
||||
value={model.query}
|
||||
/>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
||||
const selected = new Set(model.selectedIds);
|
||||
return (
|
||||
<section className="collector-content" aria-label="Gesammelte Links">
|
||||
<DataTable className="collector-table" label="Gesammelte Links">
|
||||
<DataTableHeader className="collector-table-header">
|
||||
<div className="collector-table-header-row" role="row">
|
||||
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
|
||||
<span role="columnheader">Sammlung</span>
|
||||
<span role="columnheader">URL oder Rohzeile</span>
|
||||
<span role="columnheader">Zeile</span>
|
||||
<span role="columnheader">Status</span>
|
||||
</div>
|
||||
</DataTableHeader>
|
||||
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
||||
{model.busy ? (
|
||||
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
|
||||
) : model.error ? (
|
||||
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
|
||||
) : model.empty ? (
|
||||
<DataTableEmpty
|
||||
data-visual-region="collector-empty-state"
|
||||
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
|
||||
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
|
||||
/>
|
||||
) : (
|
||||
model.rows.map((row) => (
|
||||
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
|
||||
<span className="collector-column-select" role="cell">
|
||||
<input
|
||||
aria-label="Link auswählen"
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => actions.onSelectionChange(row.id)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</span>
|
||||
<span className="collector-row-source" role="cell">{row.tabName}</span>
|
||||
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
|
||||
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
|
||||
<span className="collector-row-status" role="cell">Lokal</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
||||
if (region === "sidebar") {
|
||||
return <CollectorSidebar actions={actions} model={model} />;
|
||||
}
|
||||
if (region === "toolbar") {
|
||||
return <CollectorToolbar actions={actions} model={model} />;
|
||||
}
|
||||
if (region === "content") {
|
||||
return <CollectorContent actions={actions} model={model} />;
|
||||
}
|
||||
return (
|
||||
<div className="collector-view">
|
||||
<CollectorSidebar actions={actions} model={model} />
|
||||
<div className="collector-view-main">
|
||||
<CollectorToolbar actions={actions} model={model} />
|
||||
<CollectorContent actions={actions} model={model} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorInputDialog({
|
||||
open,
|
||||
tabName,
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
onCommit
|
||||
}: CollectorInputDialogProps): ReactElement | null {
|
||||
return (
|
||||
<Dialog
|
||||
actions={(
|
||||
<>
|
||||
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
|
||||
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
|
||||
</>
|
||||
)}
|
||||
description={`Links für ${tabName} lokal erfassen.`}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
size="wide"
|
||||
title="Links hinzufügen"
|
||||
>
|
||||
<label className="collector-input-label">
|
||||
<span>Links</span>
|
||||
<textarea
|
||||
aria-label="Links"
|
||||
autoFocus
|
||||
className="collector-input"
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
|
||||
placeholder="Eine URL oder Rohzeile pro Zeile"
|
||||
rows={12}
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export interface CollectorSourceTab {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CollectorTabSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
linkCount: number;
|
||||
}
|
||||
|
||||
export interface CollectorRow {
|
||||
id: string;
|
||||
tabId: string;
|
||||
tabName: string;
|
||||
originalLineIndex: number;
|
||||
lineNumber: number;
|
||||
value: string;
|
||||
linkCount: number;
|
||||
}
|
||||
|
||||
export interface CollectorViewModel {
|
||||
tabs: CollectorTabSummary[];
|
||||
activeTabId: string;
|
||||
rows: CollectorRow[];
|
||||
busy: boolean;
|
||||
query: string;
|
||||
selectedIds: string[];
|
||||
empty: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
function nonEmptyLines(tab: CollectorSourceTab): Array<{ originalLineIndex: number; value: string }> {
|
||||
return tab.text
|
||||
.split(/\r?\n/)
|
||||
.map((value, originalLineIndex) => ({ originalLineIndex, value: value.trim() }))
|
||||
.filter((line) => line.value.length > 0);
|
||||
}
|
||||
|
||||
export function buildCollectorRows(
|
||||
tabs: CollectorSourceTab[],
|
||||
activeTabId: string = tabs[0]?.id ?? "",
|
||||
query = ""
|
||||
): CollectorRow[] {
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
|
||||
if (!activeTab) {
|
||||
return [];
|
||||
}
|
||||
const lines = nonEmptyLines(activeTab);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("de");
|
||||
return lines
|
||||
.filter((line) => !normalizedQuery || line.value.toLocaleLowerCase("de").includes(normalizedQuery))
|
||||
.map((line) => ({
|
||||
id: `${activeTab.id}:${line.originalLineIndex}`,
|
||||
tabId: activeTab.id,
|
||||
tabName: activeTab.name,
|
||||
originalLineIndex: line.originalLineIndex,
|
||||
lineNumber: line.originalLineIndex + 1,
|
||||
value: line.value,
|
||||
linkCount: lines.length
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCollectorViewModel(
|
||||
tabs: CollectorSourceTab[],
|
||||
activeTabId: string,
|
||||
query: string,
|
||||
busy: boolean,
|
||||
selectedIds: string[],
|
||||
error = ""
|
||||
): CollectorViewModel {
|
||||
const rows = buildCollectorRows(tabs, activeTabId, query);
|
||||
return {
|
||||
tabs: tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
name: tab.name,
|
||||
linkCount: nonEmptyLines(tab).length
|
||||
})),
|
||||
activeTabId,
|
||||
rows,
|
||||
busy,
|
||||
query,
|
||||
selectedIds,
|
||||
empty: rows.length === 0,
|
||||
error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
.collector-view {
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 520px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collector-view-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collector-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.collector-sidebar-heading {
|
||||
align-items: center;
|
||||
color: var(--ui-text-secondary);
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.collector-sidebar-heading span,
|
||||
.collector-sidebar-count {
|
||||
color: var(--ui-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.collector-sidebar-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.collector-sidebar-item {
|
||||
align-items: stretch;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.collector-sidebar-item:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.collector-sidebar-item.is-active {
|
||||
background: var(--ui-active);
|
||||
border-color: var(--ui-border);
|
||||
}
|
||||
|
||||
.collector-sidebar-select,
|
||||
.collector-sidebar-remove,
|
||||
.collector-sidebar-add {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.collector-sidebar-select {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collector-sidebar-select span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-sidebar-remove {
|
||||
border-left: 1px solid var(--ui-border);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.collector-sidebar-add {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collector-sidebar-add:hover,
|
||||
.collector-sidebar-remove:hover {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.collector-toolbar {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-action {
|
||||
background: var(--ui-input);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-action:hover:not(:disabled) {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.collector-action-primary {
|
||||
background: var(--ui-primary);
|
||||
border-color: var(--ui-primary);
|
||||
color: #181A1F;
|
||||
}
|
||||
|
||||
.collector-action-primary:hover:not(:disabled) {
|
||||
background: var(--ui-primary-hover);
|
||||
color: #181A1F;
|
||||
}
|
||||
|
||||
.collector-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.collector-action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.collector-content {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collector-table {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.collector-table-header {
|
||||
height: 41px;
|
||||
}
|
||||
|
||||
.collector-table-header-row {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
height: 41px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.collector-table-header-row > span,
|
||||
.collector-row > span {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.collector-table-body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.collector-row {
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-secondary);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.collector-row:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.collector-row.is-selected {
|
||||
background: var(--ui-active);
|
||||
}
|
||||
|
||||
.collector-column-select {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.collector-row-source {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-row-value {
|
||||
color: var(--ui-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: text;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-row-line,
|
||||
.collector-row-status {
|
||||
color: var(--ui-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.collector-table-error .ui-data-table-empty-title {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.collector-input-label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collector-input-label > span {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collector-input {
|
||||
background: var(--ui-input);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
min-height: 240px;
|
||||
padding: 12px;
|
||||
resize: vertical;
|
||||
user-select: text;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-dialog-primary,
|
||||
.collector-dialog-secondary {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.collector-dialog-primary {
|
||||
background: var(--ui-primary);
|
||||
border-color: var(--ui-primary);
|
||||
color: #181A1F;
|
||||
}
|
||||
|
||||
.collector-dialog-secondary {
|
||||
background: var(--ui-modal-secondary);
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.collector-view {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.collector-sidebar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collector-action {
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
|
||||
min-width: 660px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
||||
min-width: 610px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { memo, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
|
||||
import type { DownloadItem } from "../../../shared/types";
|
||||
import {
|
||||
compactProviderLabels,
|
||||
extractHoster,
|
||||
formatAudioStripSummary,
|
||||
formatDateTime,
|
||||
formatSpeedMbps,
|
||||
humanSize,
|
||||
providerLabels
|
||||
} from "../../download-format";
|
||||
import type { DownloadPackageRow } from "./downloads-model";
|
||||
|
||||
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
|
||||
|
||||
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
|
||||
name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" },
|
||||
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
|
||||
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
|
||||
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
|
||||
account: { label: "Service", width: "132px" },
|
||||
prio: { label: "Priorität", width: "70px" },
|
||||
status: { label: "Status", width: "160px" },
|
||||
speed: { label: "Geschwindigkeit", width: "90px" },
|
||||
added: { label: "Hinzugefügt am", width: "155px" }
|
||||
};
|
||||
|
||||
export interface DownloadsTableActions {
|
||||
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
|
||||
onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
|
||||
onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void;
|
||||
onSelectionMouseEnter: (id: string) => void;
|
||||
onTogglePackage: (packageId: string) => void;
|
||||
onTogglePackageCollapse: (packageId: string) => void;
|
||||
onStartPackageRename: (packageId: string, packageName: string) => void;
|
||||
onPackageRenameChange: (name: string) => void;
|
||||
onCommitPackageRename: (packageId: string, value: string) => void;
|
||||
onCancelPackageRename: (packageId: string) => void;
|
||||
onCancelPackage: (packageId: string) => void;
|
||||
onMovePackageUp: (packageId: string) => void;
|
||||
onMovePackageDown: (packageId: string) => void;
|
||||
onRemoveItem: (itemId: string) => void;
|
||||
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
|
||||
onColumnDragStart: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
||||
onColumnDragOver: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
||||
onColumnDragLeave: () => void;
|
||||
onColumnDrop: (column: string, event: DragEvent<HTMLDivElement>) => void;
|
||||
onColumnDragEnd: () => void;
|
||||
onColumnContextMenu: (column: string, x: number, y: number) => void;
|
||||
onSortColumn: (column: DownloadSortColumn) => void;
|
||||
}
|
||||
|
||||
function displayedStatus(item: DownloadItem, sessionRunning: boolean): string {
|
||||
const value = item.fullStatus.trim();
|
||||
if (value === "Wartet") return "";
|
||||
if (sessionRunning) return value;
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") return value;
|
||||
if (value === "Paket gestoppt") return value;
|
||||
if (/^Entpacken\b/i.test(value) || /^Entpackt\b/i.test(value) || /^Entpack-Fehler\b/i.test(value) || /^Fertig\b/i.test(value)) return value;
|
||||
return "";
|
||||
}
|
||||
|
||||
function progress(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value || 0)));
|
||||
}
|
||||
|
||||
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
|
||||
const displayStatus = displayedStatus(item, sessionRunning);
|
||||
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
|
||||
const error = item.lastError.trim();
|
||||
const statusTitle = displayStatus
|
||||
? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}`
|
||||
: error;
|
||||
if (column === "name") {
|
||||
return <span className="downloads-cell downloads-name-cell downloads-copyable" title={item.fileName}><span className={`downloads-link-state ${item.onlineStatus ?? "unknown"}`} />{item.fileName}</span>;
|
||||
}
|
||||
if (column === "size") {
|
||||
const total = item.totalBytes || item.downloadedBytes || 0;
|
||||
const value = total > 0 ? progress((item.downloadedBytes / total) * 100) : 0;
|
||||
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(item.downloadedBytes)} / {humanSize(total)}</b></span> : null}</span>;
|
||||
}
|
||||
if (column === "progress") {
|
||||
const value = progress(item.progressPercent);
|
||||
return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{value}%</b></span></span>;
|
||||
}
|
||||
if (column === "hoster") {
|
||||
const hoster = extractHoster(item.url);
|
||||
return <span className="downloads-cell" title={hoster}>{hoster}</span>;
|
||||
}
|
||||
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
|
||||
if (column === "prio") return <span className="downloads-cell" />;
|
||||
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
|
||||
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
|
||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ItemRowProps {
|
||||
item: DownloadItem;
|
||||
selected: boolean;
|
||||
sessionRunning?: boolean;
|
||||
columnOrder: readonly string[];
|
||||
gridTemplate: string;
|
||||
actions: DownloadsTableActions;
|
||||
}
|
||||
|
||||
export function ItemRowContent({ item, selected, sessionRunning = true, columnOrder, gridTemplate, actions }: ItemRowProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`downloads-item-row${selected ? " is-selected" : ""}`}
|
||||
data-download-row-id={item.id}
|
||||
role="row"
|
||||
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.onSelectionMouseDown(item.id, event);
|
||||
}}
|
||||
onMouseEnter={() => actions.onSelectionMouseEnter(item.id)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId);
|
||||
}}
|
||||
>
|
||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => actions.onToggleSelection(item.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
|
||||
<span className="downloads-action-cell" role="cell"><button aria-label={`${item.fileName} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} type="button">⋮</button></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps): boolean {
|
||||
const a = previous.item;
|
||||
const b = next.item;
|
||||
return a.id === b.id
|
||||
&& a.updatedAt === b.updatedAt
|
||||
&& a.status === b.status
|
||||
&& a.fileName === b.fileName
|
||||
&& a.url === b.url
|
||||
&& a.provider === b.provider
|
||||
&& a.providerLabel === b.providerLabel
|
||||
&& a.providerAccountId === b.providerAccountId
|
||||
&& a.providerAccountLabel === b.providerAccountLabel
|
||||
&& a.fullStatus === b.fullStatus
|
||||
&& a.lastError === b.lastError
|
||||
&& a.onlineStatus === b.onlineStatus
|
||||
&& a.progressPercent === b.progressPercent
|
||||
&& a.speedBps === b.speedBps
|
||||
&& a.downloadedBytes === b.downloadedBytes
|
||||
&& a.totalBytes === b.totalBytes
|
||||
&& a.retries === b.retries
|
||||
&& a.createdAt === b.createdAt
|
||||
&& previous.selected === next.selected
|
||||
&& previous.sessionRunning === next.sessionRunning
|
||||
&& previous.columnOrder === next.columnOrder
|
||||
&& previous.gridTemplate === next.gridTemplate
|
||||
&& previous.actions === next.actions;
|
||||
}
|
||||
|
||||
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
||||
|
||||
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
let cancelled = 0;
|
||||
let extracted = 0;
|
||||
let extracting = false;
|
||||
let activeProgress = 0;
|
||||
let extractingProgress = 0;
|
||||
for (const item of row.items) {
|
||||
if (item.status === "completed") done += 1;
|
||||
else if (item.status === "failed") failed += 1;
|
||||
else if (item.status === "cancelled") cancelled += 1;
|
||||
const fullStatus = item.fullStatus || "";
|
||||
if (fullStatus.startsWith("Entpackt")) {
|
||||
extracted += 1;
|
||||
} else if (fullStatus.startsWith("Entpacken")) {
|
||||
extracting = true;
|
||||
const match = fullStatus.match(/^Entpacken\s+(\d+)%/);
|
||||
if (match) extractingProgress += Number(match[1]) / 100;
|
||||
}
|
||||
if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
|
||||
activeProgress += (item.progressPercent || 0) / 100;
|
||||
}
|
||||
}
|
||||
const total = Math.max(1, row.items.length);
|
||||
const allDownloaded = done + failed + cancelled >= total;
|
||||
const allExtracted = extracted >= total;
|
||||
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
|
||||
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
|
||||
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
||||
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
|
||||
return { done, failed, cancelled, total, value };
|
||||
}
|
||||
|
||||
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
|
||||
const entry = row.package;
|
||||
const stats = packageProgress(row);
|
||||
if (column === "name") {
|
||||
return (
|
||||
<span className="downloads-cell downloads-name-cell">
|
||||
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : "−"}</button>
|
||||
<input aria-label={`${entry.name} aktivieren`} checked={entry.enabled} onChange={() => actions.onTogglePackage(entry.id)} onClick={(event) => event.stopPropagation()} type="checkbox" />
|
||||
{editing
|
||||
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
finishRename(editingName);
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
actions.onCancelPackageRename(entry.id);
|
||||
}
|
||||
}} />
|
||||
: <strong className="downloads-copyable" onDoubleClick={(event) => { event.stopPropagation(); actions.onStartPackageRename(entry.id, entry.name); }} title={entry.name}>{entry.name}</strong>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (column === "size") {
|
||||
const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
|
||||
const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
|
||||
const value = total > 0 ? progress((downloaded / total) * 100) : 0;
|
||||
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(downloaded)} / {humanSize(total)}</b></span> : null}</span>;
|
||||
}
|
||||
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
|
||||
if (column === "hoster") {
|
||||
const value = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
|
||||
return <span className="downloads-cell" title={value}>{value}</span>;
|
||||
}
|
||||
if (column === "account") {
|
||||
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
||||
return <span className="downloads-cell" title={value}>{value}</span>;
|
||||
}
|
||||
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
||||
if (column === "status") {
|
||||
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
|
||||
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
|
||||
}
|
||||
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface PackageCardProps {
|
||||
row: DownloadPackageRow;
|
||||
selectedIds: Set<string>;
|
||||
selectedVersion: number;
|
||||
editing: boolean;
|
||||
editingName: string;
|
||||
packageSpeedBps: number;
|
||||
sessionRunning?: boolean;
|
||||
columnOrder: readonly string[];
|
||||
gridTemplate: string;
|
||||
actions: DownloadsTableActions;
|
||||
draggable?: boolean;
|
||||
onDragStart?: (packageId: string) => void;
|
||||
onDrop?: (packageId: string) => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions, draggable = true, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||
const entry = row.package;
|
||||
let renameFinished = false;
|
||||
const finishRename = (value: string): void => {
|
||||
if (renameFinished) return;
|
||||
renameFinished = true;
|
||||
actions.onCommitPackageRename(entry.id, value);
|
||||
};
|
||||
return (
|
||||
<article
|
||||
className={`package-card downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
|
||||
data-download-package-id={entry.id}
|
||||
draggable={draggable}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id);
|
||||
}}
|
||||
onDragStart={(event) => { event.stopPropagation(); onDragStart?.(entry.id); }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop?.(entry.id); }}
|
||||
onDragEnd={(event) => { event.stopPropagation(); onDragEnd?.(); }}
|
||||
>
|
||||
<div
|
||||
className="downloads-package-row"
|
||||
data-download-row-id={entry.id}
|
||||
role="row"
|
||||
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (target.closest("button, input, select")) return;
|
||||
actions.onTogglePackageCollapse(entry.id);
|
||||
}}
|
||||
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
|
||||
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
|
||||
>
|
||||
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => actions.onToggleSelection(entry.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
|
||||
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button">⋮</button></span>
|
||||
</div>
|
||||
{!row.collapsed && row.items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function arePackageCardPropsEqual(previous: PackageCardProps, next: PackageCardProps): boolean {
|
||||
const a = previous.row.package;
|
||||
const b = next.row.package;
|
||||
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
|
||||
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions || previous.draggable !== next.draggable || previous.onDragStart !== next.onDragStart || previous.onDrop !== next.onDrop || previous.onDragEnd !== next.onDragEnd) return false;
|
||||
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
|
||||
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
||||
for (const itemId of b.itemIds) {
|
||||
if (previous.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) return false;
|
||||
}
|
||||
}
|
||||
if (previous.row.items.length !== next.row.items.length) return false;
|
||||
for (let index = 0; index < previous.row.items.length; index += 1) {
|
||||
const oldItem = previous.row.items[index];
|
||||
const newItem = next.row.items[index];
|
||||
if (!oldItem || !newItem || !areItemRowPropsEqual({ actions: previous.actions, columnOrder: previous.columnOrder, gridTemplate: previous.gridTemplate, item: oldItem, selected: previous.selectedIds.has(oldItem.id), sessionRunning: previous.sessionRunning }, { actions: next.actions, columnOrder: next.columnOrder, gridTemplate: next.gridTemplate, item: newItem, selected: next.selectedIds.has(newItem.id), sessionRunning: next.sessionRunning })) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const PackageCard = memo(PackageCardContent, arePackageCardPropsEqual);
|
||||
|
||||
export interface DownloadsTableHeaderProps {
|
||||
actions: DownloadsTableActions;
|
||||
columnOrder: readonly string[];
|
||||
gridTemplate: string;
|
||||
sortColumn: DownloadSortColumn;
|
||||
sortDirection: "asc" | "desc";
|
||||
selectedCount: number;
|
||||
visibleIds: string[];
|
||||
}
|
||||
|
||||
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
|
||||
return (
|
||||
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}>
|
||||
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => {
|
||||
const definition = downloadColumnDefinitions[column];
|
||||
if (!definition) return null;
|
||||
return (
|
||||
<div
|
||||
className="downloads-column-header"
|
||||
draggable
|
||||
key={column}
|
||||
onContextMenu={(event) => { event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
|
||||
onDragEnd={actions.onColumnDragEnd}
|
||||
onDragLeave={actions.onColumnDragLeave}
|
||||
onDragOver={(event) => actions.onColumnDragOver(column, event)}
|
||||
onDragStart={(event) => actions.onColumnDragStart(column, event)}
|
||||
onDrop={(event) => actions.onColumnDrop(column, event)}
|
||||
role="columnheader"
|
||||
>
|
||||
{definition.sortable
|
||||
? <button onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
|
||||
: definition.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="downloads-action-cell" role="columnheader">Aktion</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
||||
import {
|
||||
DownloadsTableHeader,
|
||||
ItemRow,
|
||||
PackageCard,
|
||||
type DownloadSortColumn,
|
||||
type DownloadsTableActions
|
||||
} from "./DownloadsTable";
|
||||
import "./downloads.css";
|
||||
|
||||
export interface DownloadsStatusModel {
|
||||
packages: number;
|
||||
links: number;
|
||||
session: string;
|
||||
total: string;
|
||||
hosters: number;
|
||||
speed: string;
|
||||
eta: string;
|
||||
}
|
||||
|
||||
export interface DownloadsViewModel extends DownloadsViewModelCore {
|
||||
running: boolean;
|
||||
paused: boolean;
|
||||
canStart: boolean;
|
||||
canPause: boolean;
|
||||
canStop: boolean;
|
||||
actionBusy: boolean;
|
||||
reconnectSeconds: number;
|
||||
reconnectReason: string;
|
||||
clipboardWatcher: boolean;
|
||||
scheduleActive: boolean;
|
||||
scheduleOpen: boolean;
|
||||
scheduleTime: string;
|
||||
scheduleLabel: string;
|
||||
packageSpeedBps: Record<string, number>;
|
||||
editingPackageId: string | null;
|
||||
editingName: string;
|
||||
columnOrder: readonly string[];
|
||||
gridTemplate: string;
|
||||
sortColumn?: DownloadSortColumn;
|
||||
sortDirection?: "asc" | "desc";
|
||||
status: DownloadsStatusModel;
|
||||
}
|
||||
|
||||
export interface DownloadsViewActions extends DownloadsTableActions {
|
||||
onDisplayModeChange: (mode: DownloadDisplayMode) => void;
|
||||
onFilterChange: (filter: DownloadSidebarFilter) => void;
|
||||
onProviderFilterChange: (provider: string) => void;
|
||||
onQueryChange: (query: string) => void;
|
||||
onAddLinks: () => void;
|
||||
onStartDownloads: () => void;
|
||||
onPauseDownloads: () => void;
|
||||
onStopDownloads: () => void;
|
||||
onToggleSchedule: () => void;
|
||||
onScheduleTimeChange: (value: string) => void;
|
||||
onActivateSchedule: () => void;
|
||||
onCancelSchedule: () => void;
|
||||
onMoveSelectionUp: () => void;
|
||||
onMoveSelectionDown: () => void;
|
||||
onRenameSelection: () => void;
|
||||
onRemoveSelection: () => void;
|
||||
onToggleClipboardWatcher: () => void;
|
||||
onClearAll: () => void;
|
||||
onToggleAllPackages: () => void;
|
||||
onShowAllPackages: () => void;
|
||||
onPackageDragStart: (packageId: string) => void;
|
||||
onPackageDrop: (packageId: string) => void;
|
||||
onPackageDragEnd: () => void;
|
||||
}
|
||||
|
||||
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||
{ id: "all", label: "Alle" },
|
||||
{ id: "active", label: "Aktiv" },
|
||||
{ id: "queued", label: "Wartend" },
|
||||
{ id: "paused", label: "Pausiert" },
|
||||
{ id: "completed", label: "Fertig" },
|
||||
{ id: "failed", label: "Fehler" }
|
||||
];
|
||||
|
||||
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
return (
|
||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||
<div className="downloads-mode-switch" role="group" aria-label="Downloadansicht">
|
||||
<button className={model.displayMode === "packages" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("packages")} type="button">Pakete</button>
|
||||
<button className={model.displayMode === "files" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("files")} type="button">Dateien</button>
|
||||
</div>
|
||||
<nav aria-label="Downloadfilter">
|
||||
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||
</nav>
|
||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||
<div className="downloads-sidebar-actions">
|
||||
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
|
||||
<label><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
|
||||
const entries = [
|
||||
["Pakete", String(model.status.packages)],
|
||||
["Links", String(model.status.links)],
|
||||
["Sitzung", model.status.session],
|
||||
["Gesamt", model.status.total],
|
||||
["Hoster", String(model.status.hosters)],
|
||||
["Geschwindigkeit", model.status.speed],
|
||||
["ETA", model.status.eta]
|
||||
];
|
||||
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</section>;
|
||||
}
|
||||
|
||||
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
const hasSelection = model.actionableSelectedIds.length > 0;
|
||||
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
|
||||
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
|
||||
return (
|
||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
||||
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
|
||||
<span className="downloads-toolbar-divider" />
|
||||
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
|
||||
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||
{model.scheduleActive
|
||||
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
|
||||
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
|
||||
<span className="downloads-toolbar-divider" />
|
||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
|
||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tableState(model: DownloadsViewModel): ReactElement | null {
|
||||
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
|
||||
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
|
||||
return model.packageRows.map((row: DownloadPackageRow) => (
|
||||
<PackageCard
|
||||
actions={actions}
|
||||
columnOrder={model.columnOrder}
|
||||
editing={model.editingPackageId === row.package.id}
|
||||
editingName={model.editingName}
|
||||
gridTemplate={model.gridTemplate}
|
||||
key={row.package.id}
|
||||
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
|
||||
onDragEnd={actions.onPackageDragEnd}
|
||||
onDragStart={actions.onPackageDragStart}
|
||||
onDrop={actions.onPackageDrop}
|
||||
row={row}
|
||||
selectedIds={model.selectedIds}
|
||||
selectedVersion={model.actionableSelectedIds.length}
|
||||
sessionRunning={model.running}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
return (
|
||||
<main className="downloads-content">
|
||||
<div className="downloads-table" role="table" aria-label="Downloads">
|
||||
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
|
||||
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
|
||||
{tableState(model)}
|
||||
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
|
||||
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
return (
|
||||
<footer className="downloads-footer" data-visual-region="downloads-pagination">
|
||||
<span>{model.paginationLabel}</span>
|
||||
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
|
||||
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadsView({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
return (
|
||||
<div className="downloads-view">
|
||||
<div className="downloads-side-column"><DownloadsSidebar actions={actions} model={model} /><DownloadsSidebarStatus model={model} /></div>
|
||||
<div className="downloads-main-column"><DownloadsToolbar actions={actions} model={model} /><DownloadsContent actions={actions} model={model} /><DownloadsFooter actions={actions} model={model} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
||||
|
||||
export type DownloadDisplayMode = "packages" | "files";
|
||||
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
|
||||
|
||||
export interface DownloadsModelInput {
|
||||
packageOrder: string[];
|
||||
packages: Record<string, PackageEntry>;
|
||||
items: Record<string, DownloadItem>;
|
||||
displayMode: DownloadDisplayMode;
|
||||
filter: DownloadSidebarFilter;
|
||||
providerFilter: string;
|
||||
query: string;
|
||||
collapsedPackageIds: Iterable<string>;
|
||||
selectedIds: Iterable<string>;
|
||||
hideExtractedItems: boolean;
|
||||
showAllPackages: boolean;
|
||||
renderLimit: number;
|
||||
}
|
||||
|
||||
export interface DownloadFilterCounts {
|
||||
all: number;
|
||||
active: number;
|
||||
queued: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface DownloadPackageRow {
|
||||
package: PackageEntry;
|
||||
items: DownloadItem[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadsViewModelCore {
|
||||
displayMode: DownloadDisplayMode;
|
||||
filter: DownloadSidebarFilter;
|
||||
providerFilter: string;
|
||||
providerOptions: Array<{ id: string; label: string }>;
|
||||
query: string;
|
||||
counts: DownloadFilterCounts;
|
||||
packageRows: DownloadPackageRow[];
|
||||
fileRows: DownloadItem[];
|
||||
visibleItemIds: string[];
|
||||
visibleRowIds: string[];
|
||||
actionableSelectedIds: string[];
|
||||
actionableSelectedPackageIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
mainRowCount: number;
|
||||
totalMainRowCount: number;
|
||||
paginationLabel: string;
|
||||
limited: boolean;
|
||||
empty: boolean;
|
||||
filteredEmpty: boolean;
|
||||
}
|
||||
|
||||
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
|
||||
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
|
||||
|
||||
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
|
||||
if (activeStatuses.has(status)) return "active";
|
||||
if (queuedStatuses.has(status)) return "queued";
|
||||
if (status === "paused" || status === "completed" || status === "failed") return status;
|
||||
return "all";
|
||||
}
|
||||
|
||||
export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): DownloadFilterCounts {
|
||||
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
|
||||
for (const item of items) {
|
||||
counts.all += 1;
|
||||
const category = classifyDownloadStatus(item.status);
|
||||
if (category !== "all") counts[category] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function isExtracted(item: DownloadItem): boolean {
|
||||
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
||||
}
|
||||
|
||||
function matchesQuery(value: string | undefined, query: string): boolean {
|
||||
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
||||
}
|
||||
|
||||
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
|
||||
return filter === "all" || classifyDownloadStatus(item.status) === filter;
|
||||
}
|
||||
|
||||
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
|
||||
return providerFilter === "all" || item.provider === providerFilter;
|
||||
}
|
||||
|
||||
function isActivePackage(row: DownloadPackageRow): boolean {
|
||||
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
|
||||
}
|
||||
|
||||
function paginationLabel(visible: number, total: number): string {
|
||||
if (visible === 0 || total === 0) return "0 von 0";
|
||||
return `1–${visible} von ${total}`;
|
||||
}
|
||||
|
||||
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
|
||||
const allPackages = input.packageOrder
|
||||
.map((id) => input.packages[id])
|
||||
.filter((entry): entry is PackageEntry => Boolean(entry));
|
||||
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
|
||||
const counts = buildDownloadSidebarCounts(allItems);
|
||||
const providerMap = new Map<string, string>();
|
||||
for (const entry of allItems) {
|
||||
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
||||
}
|
||||
|
||||
const query = input.query.trim().toLocaleLowerCase("de-DE");
|
||||
const collapsed = new Set(input.collapsedPackageIds);
|
||||
const selectedIds = new Set(input.selectedIds);
|
||||
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
||||
const items = entry.itemIds
|
||||
.map((id) => input.items[id])
|
||||
.filter((item): item is DownloadItem => Boolean(item))
|
||||
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
|
||||
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
|
||||
const matchingItems = items.filter((item) => {
|
||||
const itemMatchesQuery = query === ""
|
||||
|| matchesQuery(item.fileName, query)
|
||||
|| matchesQuery(item.targetPath, query)
|
||||
|| matchesQuery(item.providerLabel, query)
|
||||
|| matchesQuery(item.providerAccountLabel, query)
|
||||
|| matchesQuery(item.fullStatus, query)
|
||||
|| matchesQuery(item.lastError, query);
|
||||
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
|
||||
});
|
||||
if (matchingItems.length === 0) return [];
|
||||
const visibleItems = packageMatchesQuery && query !== ""
|
||||
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
||||
: matchingItems;
|
||||
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
|
||||
});
|
||||
|
||||
const totalPackageRows = packageRows.length;
|
||||
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
|
||||
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
|
||||
const activeRows = packageRows.filter(isActivePackage);
|
||||
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
|
||||
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
|
||||
}
|
||||
|
||||
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
|
||||
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
|
||||
const visibleItemIds = (input.displayMode === "files"
|
||||
? fileRows
|
||||
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
|
||||
const visibleRowIds = input.displayMode === "files"
|
||||
? visibleItemIds
|
||||
: displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]);
|
||||
const visibleRowSet = new Set(visibleRowIds);
|
||||
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
|
||||
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
|
||||
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
|
||||
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
|
||||
const totalMainRowCount = input.displayMode === "files"
|
||||
? fileRows.length
|
||||
: totalPackageRows;
|
||||
|
||||
return {
|
||||
displayMode: input.displayMode,
|
||||
filter: input.filter,
|
||||
providerFilter: input.providerFilter,
|
||||
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
|
||||
query: input.query,
|
||||
counts,
|
||||
packageRows: displayedPackages,
|
||||
fileRows,
|
||||
visibleItemIds,
|
||||
visibleRowIds,
|
||||
actionableSelectedIds,
|
||||
actionableSelectedPackageIds,
|
||||
selectedIds,
|
||||
mainRowCount,
|
||||
totalMainRowCount,
|
||||
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
||||
limited: mainRowCount < totalMainRowCount,
|
||||
empty: allItems.length === 0,
|
||||
filteredEmpty: allItems.length > 0 && mainRowCount === 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
.downloads-view {
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--ui-canvas);
|
||||
}
|
||||
|
||||
.downloads-side-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--ui-border);
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.downloads-sidebar,
|
||||
.downloads-sidebar-status,
|
||||
.downloads-toolbar,
|
||||
.downloads-content,
|
||||
.downloads-footer {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.downloads-copyable,
|
||||
.downloads-search-input,
|
||||
.downloads-rename-input {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.downloads-sidebar {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.downloads-mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.downloads-mode-switch button,
|
||||
.downloads-sidebar nav button,
|
||||
.downloads-sidebar-actions button {
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--ui-text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-mode-switch button {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloads-mode-switch button.is-active,
|
||||
.downloads-sidebar nav button.is-active {
|
||||
color: #ffffff;
|
||||
background: var(--ui-accent);
|
||||
}
|
||||
|
||||
.downloads-sidebar nav {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.downloads-sidebar nav button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.downloads-provider-filter,
|
||||
.downloads-sidebar-search {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.downloads-provider-filter select,
|
||||
.downloads-sidebar-search input,
|
||||
.downloads-schedule-controls input,
|
||||
.downloads-rename-input {
|
||||
height: 36px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 10px;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-input);
|
||||
}
|
||||
|
||||
.downloads-sidebar-actions {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.downloads-sidebar-actions label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.downloads-sidebar-status {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.downloads-sidebar-status div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.downloads-sidebar-status strong {
|
||||
color: var(--ui-text);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.downloads-main-column {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.downloads-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 52px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.downloads-toolbar button,
|
||||
.downloads-footer button,
|
||||
.downloads-action-cell button,
|
||||
.downloads-collapse-button,
|
||||
.downloads-column-header button {
|
||||
min-height: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 10px;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-modal-secondary);
|
||||
}
|
||||
|
||||
.downloads-toolbar button:disabled,
|
||||
.downloads-footer button:disabled {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.downloads-toolbar-divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 1px 2px;
|
||||
background: var(--ui-border);
|
||||
}
|
||||
|
||||
.downloads-schedule-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.downloads-schedule-controls input {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.downloads-sidebar-search span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.downloads-content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--ui-canvas);
|
||||
}
|
||||
|
||||
.downloads-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.downloads-table-header {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
height: 41px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
min-width: max-content;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-muted);
|
||||
background: var(--ui-table-header);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.downloads-table-body {
|
||||
min-width: max-content;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.downloads-package-card {
|
||||
min-width: max-content;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: var(--ui-canvas);
|
||||
}
|
||||
|
||||
.downloads-package-card.is-disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.downloads-item-row,
|
||||
.downloads-package-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
min-width: max-content;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-canvas);
|
||||
}
|
||||
|
||||
.downloads-item-row {
|
||||
border-top: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.downloads-item-row.is-selected,
|
||||
.downloads-package-card.is-selected > .downloads-package-row {
|
||||
background: var(--ui-active);
|
||||
}
|
||||
|
||||
.downloads-cell-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloads-cell,
|
||||
.downloads-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloads-name-cell strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloads-selection-cell,
|
||||
.downloads-action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloads-action-cell button,
|
||||
.downloads-collapse-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.downloads-column-header {
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloads-column-header button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
text-transform: inherit;
|
||||
}
|
||||
|
||||
.downloads-link-state {
|
||||
flex: 0 0 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.downloads-link-state.online {
|
||||
background: var(--ui-primary);
|
||||
}
|
||||
|
||||
.downloads-link-state.offline {
|
||||
background: var(--ui-danger);
|
||||
}
|
||||
|
||||
.downloads-link-state.checking {
|
||||
background: var(--ui-warning);
|
||||
}
|
||||
|
||||
.downloads-meter {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ui-modal-secondary);
|
||||
}
|
||||
|
||||
.downloads-meter > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: color-mix(in srgb, var(--ui-accent) 58%, transparent);
|
||||
}
|
||||
|
||||
.downloads-meter > b {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloads-empty-state,
|
||||
.downloads-table-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 240px;
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.downloads-empty-state > div,
|
||||
.downloads-table-message > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloads-empty-state strong,
|
||||
.downloads-table-message strong {
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.downloads-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
height: 60px;
|
||||
padding: 0 12px 0 60px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-muted);
|
||||
background: var(--ui-surface);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.downloads-view {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.downloads-side-column {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.downloads-toolbar,
|
||||
.downloads-footer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableFooter,
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
|
||||
import "./history.css";
|
||||
|
||||
export interface HistoryViewActions {
|
||||
onFilterChange: (filter: HistoryFilter) => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleSelection: (entryId: string) => void;
|
||||
onToggleSelectAll: (visibleIds: string[]) => void;
|
||||
onToggleExpansion: (entryId: string) => void;
|
||||
onRestore: (entryIds: string[]) => void;
|
||||
onReveal: (entryId: string) => void;
|
||||
onRemove: (entryIds: string[]) => void;
|
||||
onClearSelection: () => void;
|
||||
onClearHistory: () => void;
|
||||
onContextMenu: (entryId: string, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
export interface HistoryViewProps {
|
||||
model: HistoryViewModel;
|
||||
actions: HistoryViewActions;
|
||||
}
|
||||
|
||||
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
|
||||
{ id: "all", label: "Alle Einträge" },
|
||||
{ id: "today", label: "Heute" },
|
||||
{ id: "week", label: "Letzte 7 Tage" },
|
||||
{ id: "older", label: "Älter" },
|
||||
{ id: "completed", label: "Fertig" },
|
||||
{ id: "deleted", label: "Gelöscht" },
|
||||
{ id: "failed", label: "Fehlgeschlagen" }
|
||||
];
|
||||
|
||||
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
|
||||
return (
|
||||
<div className="history-detail-row" role="row">
|
||||
<div className="history-detail-cell" role="cell">
|
||||
<dl className="history-details-grid">
|
||||
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
|
||||
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
|
||||
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
|
||||
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
|
||||
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
|
||||
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistorySidebar({ model, actions }: HistoryViewProps): ReactElement {
|
||||
return (
|
||||
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
|
||||
<strong className="history-sidebar-heading">Verlauf</strong>
|
||||
<div className="history-filter-list">
|
||||
{filterItems.map((item) => (
|
||||
<button
|
||||
aria-current={model.filter === item.id ? "page" : undefined}
|
||||
className={`history-filter${model.filter === item.id ? " is-active" : ""}`}
|
||||
key={item.id}
|
||||
onClick={() => actions.onFilterChange(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{model.counts[item.id]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="history-sidebar-clear"
|
||||
disabled={model.totalCount === 0 || model.loading}
|
||||
onClick={actions.onClearHistory}
|
||||
type="button"
|
||||
>Verlauf leeren</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
|
||||
const selectedIds = model.selectedIds;
|
||||
const selectedSet = new Set(selectedIds);
|
||||
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
|
||||
return (
|
||||
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
|
||||
<ToolbarGroup label="Einträge">
|
||||
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
|
||||
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
|
||||
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
|
||||
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSearch
|
||||
label="Verlauf durchsuchen"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => actions.onQueryChange(event.target.value)}
|
||||
placeholder="Name, Pfad, Hoster oder Provider"
|
||||
value={model.query}
|
||||
/>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||
const selected = new Set(model.selectedIds);
|
||||
const expanded = new Set(model.expandedIds);
|
||||
const visibleIds = model.rows.map((row) => row.id);
|
||||
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
||||
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
|
||||
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
||||
? "Noch kein Verlauf"
|
||||
: "Keine passenden Einträge";
|
||||
|
||||
return (
|
||||
<section aria-label="Verlaufstabelle" className="history-content">
|
||||
<DataTable className="history-table" label="Verlauf">
|
||||
<DataTableHeader className="history-table-header">
|
||||
<div className="history-table-header-row" role="row">
|
||||
<span className="history-column-select" role="columnheader">
|
||||
<input
|
||||
aria-label="Alle sichtbaren Einträge auswählen"
|
||||
checked={allVisibleSelected}
|
||||
disabled={visibleIds.length === 0}
|
||||
onChange={() => actions.onToggleSelectAll(visibleIds)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</span>
|
||||
<span role="columnheader">Paket / Datei</span>
|
||||
<span role="columnheader">Status</span>
|
||||
<span role="columnheader">Größe</span>
|
||||
<span role="columnheader">Hoster</span>
|
||||
<span role="columnheader">Gestartet</span>
|
||||
<span role="columnheader">Beendet</span>
|
||||
<span role="columnheader">Aktion</span>
|
||||
</div>
|
||||
</DataTableHeader>
|
||||
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
|
||||
{model.loading ? (
|
||||
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
|
||||
) : model.error ? (
|
||||
<DataTableEmpty className="history-table-error" description="Öffne die Ansicht erneut, um es noch einmal zu versuchen." title={model.error} />
|
||||
) : showEmpty ? (
|
||||
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
|
||||
) : (
|
||||
model.rows.map((row) => {
|
||||
const isSelected = selected.has(row.id);
|
||||
const isExpanded = expanded.has(row.id);
|
||||
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.querySelector<HTMLButtonElement>(".history-row-action button")?.focus({ preventScroll: true });
|
||||
actions.onContextMenu(row.id, event.clientX, event.clientY);
|
||||
};
|
||||
return (
|
||||
<div className="history-row-group" key={row.id}>
|
||||
<div
|
||||
className={`history-row${isSelected ? " is-selected" : ""}`}
|
||||
data-history-row-id={row.id}
|
||||
onContextMenu={onContextMenu}
|
||||
role="row"
|
||||
>
|
||||
<span className="history-column-select" role="cell">
|
||||
<input
|
||||
aria-label={`${row.name} auswählen`}
|
||||
checked={isSelected}
|
||||
onChange={() => actions.onToggleSelection(row.id)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</span>
|
||||
<span className="history-row-name" role="cell">
|
||||
<button
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? "Details ausblenden" : "Details anzeigen"}
|
||||
className="history-expand"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.onToggleExpansion(row.id);
|
||||
}}
|
||||
type="button"
|
||||
>{isExpanded ? "−" : "+"}</button>
|
||||
<span title={row.name}>{row.name}</span>
|
||||
</span>
|
||||
<span role="cell"><span className={`history-status history-status-${row.status}`}>{row.statusLabel}</span></span>
|
||||
<span className="history-row-size" role="cell" title={row.sizeLabel}>{row.sizeLabel}</span>
|
||||
<span className="history-row-hoster" role="cell" title={row.hoster}>{row.hoster}</span>
|
||||
<span className="history-row-time" role="cell">{row.startedLabel}</span>
|
||||
<span className="history-row-time" role="cell">{row.completedLabel}</span>
|
||||
<span className="history-row-action" role="cell">
|
||||
<button
|
||||
aria-label={`Aktionen für ${row.name}`}
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
actions.onContextMenu(row.id, rect.right, rect.bottom);
|
||||
}}
|
||||
type="button"
|
||||
>•••</button>
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <HistoryRowDetails row={row} /> : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
|
||||
const count = model.rows.length;
|
||||
return (
|
||||
<DataTableFooter
|
||||
className="history-pagination"
|
||||
data-visual-region="history-pagination"
|
||||
pageSize={count}
|
||||
paginationVisible
|
||||
rangeLabel={count === 0 ? "0 von 0" : `1–${count} von ${count}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
|
||||
return (
|
||||
<div className="history-workspace-view">
|
||||
<HistorySidebar actions={actions} model={model} />
|
||||
<div className="history-view-main">
|
||||
<HistoryToolbar actions={actions} model={model} />
|
||||
<HistoryContent actions={actions} model={model} />
|
||||
<HistoryFooter model={model} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { DebridProvider, HistoryEntry } from "../../../shared/types";
|
||||
|
||||
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed";
|
||||
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
|
||||
export type HistoryViewEntry = Omit<HistoryEntry, "status"> & { status: HistoryViewStatus };
|
||||
|
||||
export interface HistoryRow extends HistoryViewEntry {
|
||||
hoster: string;
|
||||
providerLabel: string;
|
||||
startAt: number;
|
||||
sizeLabel: string;
|
||||
startedLabel: string;
|
||||
completedLabel: string;
|
||||
durationLabel: string;
|
||||
averageSpeedLabel: string;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export interface HistoryFilterCounts {
|
||||
all: number;
|
||||
today: number;
|
||||
week: number;
|
||||
older: number;
|
||||
completed: number;
|
||||
deleted: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface HistoryViewModel {
|
||||
rows: HistoryRow[];
|
||||
filter: HistoryFilter;
|
||||
query: string;
|
||||
selectedIds: string[];
|
||||
expandedIds: string[];
|
||||
counts: HistoryFilterCounts;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
megadebrid: "Mega-Debrid",
|
||||
"megadebrid-api": "Mega-Debrid API",
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
linksnappy: "LinkSnappy"
|
||||
};
|
||||
|
||||
const statusLabels: Record<HistoryViewStatus, string> = {
|
||||
completed: "Abgeschlossen",
|
||||
deleted: "Gelöscht",
|
||||
failed: "Fehlgeschlagen"
|
||||
};
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
|
||||
if (safe < 1024) {
|
||||
return `${Math.round(safe)} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = safe / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number): string {
|
||||
const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function localDayStart(timestamp: number): number {
|
||||
const date = new Date(timestamp);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, now: number): boolean {
|
||||
if (filter === "completed" || filter === "deleted" || filter === "failed") {
|
||||
return entry.status === filter;
|
||||
}
|
||||
if (filter === "all") {
|
||||
return true;
|
||||
}
|
||||
const todayStart = localDayStart(now);
|
||||
const tomorrowStartDate = new Date(todayStart);
|
||||
tomorrowStartDate.setDate(tomorrowStartDate.getDate() + 1);
|
||||
const tomorrowStart = tomorrowStartDate.getTime();
|
||||
const weekStartDate = new Date(todayStart);
|
||||
weekStartDate.setDate(weekStartDate.getDate() - 6);
|
||||
const weekStart = weekStartDate.getTime();
|
||||
if (filter === "today") {
|
||||
return entry.completedAt >= todayStart && entry.completedAt < tomorrowStart;
|
||||
}
|
||||
if (filter === "week") {
|
||||
return entry.completedAt >= weekStart && entry.completedAt < todayStart;
|
||||
}
|
||||
return entry.completedAt < weekStart;
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string): string {
|
||||
return value.trim().toLocaleLowerCase("de-DE");
|
||||
}
|
||||
|
||||
export function deriveHistoryHoster(urls: string[] | undefined): string {
|
||||
const hostnames: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of urls ?? []) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
continue;
|
||||
}
|
||||
const hostname = url.hostname.toLocaleLowerCase("de-DE");
|
||||
if (!hostname || seen.has(hostname)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(hostname);
|
||||
hostnames.push(hostname);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return hostnames.length > 0 ? hostnames.join(", ") : "—";
|
||||
}
|
||||
|
||||
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
|
||||
const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0);
|
||||
const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000;
|
||||
return Math.max(0, completedAt - durationMs);
|
||||
}
|
||||
|
||||
function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
|
||||
const hoster = deriveHistoryHoster(entry.urls);
|
||||
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
|
||||
const startAt = deriveHistoryStartAt(entry);
|
||||
const durationSeconds = Math.max(0, entry.durationSeconds || 0);
|
||||
const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0;
|
||||
return {
|
||||
...entry,
|
||||
hoster,
|
||||
providerLabel,
|
||||
startAt,
|
||||
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
|
||||
startedLabel: dateFormatter.format(new Date(startAt)),
|
||||
completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))),
|
||||
durationLabel: formatDuration(durationSeconds),
|
||||
averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
|
||||
statusLabel: statusLabels[entry.status]
|
||||
};
|
||||
}
|
||||
|
||||
export function filterHistoryRows(
|
||||
entries: HistoryViewEntry[],
|
||||
filter: HistoryFilter,
|
||||
query: string,
|
||||
now = Date.now()
|
||||
): HistoryRow[] {
|
||||
const normalizedQuery = normalizeSearch(query);
|
||||
return entries
|
||||
.filter((entry) => matchesTemporalFilter(entry, filter, now))
|
||||
.map(toHistoryRow)
|
||||
.filter((row) => {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
const searchable = [
|
||||
row.name,
|
||||
row.outputDir,
|
||||
row.hoster,
|
||||
row.providerLabel,
|
||||
...(row.urls ?? [])
|
||||
].join("\n").toLocaleLowerCase("de-DE");
|
||||
return searchable.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
|
||||
function countHistoryFilters(entries: HistoryViewEntry[], now: number): HistoryFilterCounts {
|
||||
return {
|
||||
all: entries.length,
|
||||
today: entries.filter((entry) => matchesTemporalFilter(entry, "today", now)).length,
|
||||
week: entries.filter((entry) => matchesTemporalFilter(entry, "week", now)).length,
|
||||
older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length,
|
||||
completed: entries.filter((entry) => entry.status === "completed").length,
|
||||
deleted: entries.filter((entry) => entry.status === "deleted").length,
|
||||
failed: entries.filter((entry) => entry.status === "failed").length
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHistoryViewModel(
|
||||
entries: HistoryViewEntry[],
|
||||
filter: HistoryFilter,
|
||||
query: string,
|
||||
selectedIds: Iterable<string>,
|
||||
expandedIds: Iterable<string>,
|
||||
loading: boolean,
|
||||
error: string,
|
||||
now = Date.now()
|
||||
): HistoryViewModel {
|
||||
const rows = filterHistoryRows(entries, filter, query, now);
|
||||
const visibleIds = new Set(rows.map((row) => row.id));
|
||||
return {
|
||||
rows,
|
||||
filter,
|
||||
query,
|
||||
selectedIds: [...selectedIds].filter((id) => visibleIds.has(id)),
|
||||
expandedIds: [...expandedIds],
|
||||
counts: countHistoryFilters(entries, now),
|
||||
loading,
|
||||
error,
|
||||
totalCount: entries.length
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneHistoryIds(current: Set<string>, availableIds: Iterable<string>): Set<string> {
|
||||
if (current.size === 0) {
|
||||
return current;
|
||||
}
|
||||
const available = new Set(availableIds);
|
||||
const next = new Set<string>();
|
||||
for (const id of current) {
|
||||
if (available.has(id)) {
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next.size === current.size ? current : next;
|
||||
}
|
||||
|
||||
export function selectVisibleHistoryIds(visibleIds: Iterable<string>): Set<string> {
|
||||
return new Set(visibleIds);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
.history-workspace-view {
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 520px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-view-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.history-sidebar-heading {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.history-filter-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.history-filter,
|
||||
.history-sidebar-clear,
|
||||
.history-action {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text-secondary);
|
||||
display: flex;
|
||||
font: inherit;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.history-filter {
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-filter span:last-child {
|
||||
color: var(--ui-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.history-filter:hover,
|
||||
.history-sidebar-clear:hover:not(:disabled),
|
||||
.history-action:hover:not(:disabled) {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.history-filter.is-active {
|
||||
background: var(--ui-active);
|
||||
border-color: var(--ui-border);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.history-sidebar-clear {
|
||||
border-color: var(--ui-border);
|
||||
justify-content: flex-start;
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-sidebar-clear:disabled,
|
||||
.history-action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.history-sidebar,
|
||||
.history-workspace-toolbar,
|
||||
.history-content,
|
||||
.history-pagination {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.history-workspace-toolbar {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-workspace-toolbar .ui-toolbar-search-input {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.history-action {
|
||||
background: var(--ui-input);
|
||||
border-color: var(--ui-border);
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.history-content {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-content .history-table {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history-table-header-row,
|
||||
.history-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px;
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.history-table > .history-table-header {
|
||||
height: 41px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.history-table-header-row {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
height: 41px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-table-header-row > span,
|
||||
.history-row > span {
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.history-table > .history-table-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.history-row-group {
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.history-row {
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-secondary);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.history-row:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.history-row.is-selected {
|
||||
background: var(--ui-active);
|
||||
}
|
||||
|
||||
.history-column-select {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.history-row-name {
|
||||
align-items: center;
|
||||
color: var(--ui-text);
|
||||
display: flex;
|
||||
font-weight: 600;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-row-name > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-expand,
|
||||
.history-row-action button {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--ui-text-secondary);
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.history-expand {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.history-row-action button {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.history-expand:hover,
|
||||
.history-row-action button:hover {
|
||||
background: var(--ui-hover);
|
||||
border-color: var(--ui-border);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.history-status {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-status-completed {
|
||||
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
|
||||
color: var(--ui-success);
|
||||
}
|
||||
|
||||
.history-status-deleted {
|
||||
background: color-mix(in srgb, var(--ui-warning) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
|
||||
color: var(--ui-warning);
|
||||
}
|
||||
|
||||
.history-status-failed {
|
||||
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.history-row-size,
|
||||
.history-row-hoster,
|
||||
.history-row-time {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-row-size,
|
||||
.history-row-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.history-row-action {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.history-detail-row {
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: var(--ui-input);
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.history-detail-cell {
|
||||
padding: 14px 48px;
|
||||
}
|
||||
|
||||
.history-details-grid {
|
||||
display: grid;
|
||||
gap: 12px 24px;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.history-details-grid > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-details-grid dt {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-details-grid dd {
|
||||
color: var(--ui-text-secondary);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-details-grid .history-detail-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.history-copyable {
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.history-table-error .ui-data-table-empty-title {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.history-pagination {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.history-workspace-view {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.history-sidebar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-action {
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.history-table-header-row,
|
||||
.history-row {
|
||||
grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px;
|
||||
min-width: 996px;
|
||||
}
|
||||
|
||||
.history-row-group,
|
||||
.history-detail-row {
|
||||
min-width: 996px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.history-table-header-row,
|
||||
.history-row {
|
||||
grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px;
|
||||
min-width: 932px;
|
||||
}
|
||||
|
||||
.history-row-group,
|
||||
.history-detail-row {
|
||||
min-width: 932px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import {
|
||||
cloneElement,
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
type UIEvent
|
||||
} from "react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import {
|
||||
ACCOUNT_COLUMNS,
|
||||
type AccountAddFilter,
|
||||
type AccountAddOption,
|
||||
type AccountRowViewModel
|
||||
} from "./settings-model";
|
||||
|
||||
export type AccountWorkspacePanel = "overview" | "rules";
|
||||
|
||||
export interface AccountRulesViewModel {
|
||||
providerOrder: readonly string[];
|
||||
routing: readonly string[];
|
||||
autoFallback: boolean;
|
||||
rememberCredentials?: boolean;
|
||||
rotationEvents?: readonly { id: string; title: string; detail: string }[];
|
||||
routingEntries?: readonly {
|
||||
hosterId: string;
|
||||
hosterLabel: string;
|
||||
provider: string;
|
||||
providers: readonly { value: string; label: string }[];
|
||||
}[];
|
||||
availableRoutingHosters?: readonly { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface AccountWorkspaceViewModel {
|
||||
activePanel: AccountWorkspacePanel;
|
||||
rows: readonly AccountRowViewModel[];
|
||||
selectedIds: readonly string[];
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
allEnabled?: boolean;
|
||||
statusSort?: "none" | "desc" | "asc";
|
||||
rules: AccountRulesViewModel;
|
||||
}
|
||||
|
||||
export interface AccountWorkspaceActions {
|
||||
onPanelChange: (panel: AccountWorkspacePanel) => void;
|
||||
onSelect: (rowId: string) => void;
|
||||
onToggleEnabled: (rowId: string) => void;
|
||||
onEdit: (rowId: string) => void;
|
||||
onContextMenu: (rowId: string, x: number, y: number) => void;
|
||||
onAdd: () => void;
|
||||
onRemoveSelected: () => void;
|
||||
onCheckAll: () => void;
|
||||
onSetAllEnabled?: (enabled: boolean) => void;
|
||||
onStatusSort?: () => void;
|
||||
onMoveProvider?: (index: number, direction: -1 | 1) => void;
|
||||
onProviderDragStart?: (event: DragEvent<HTMLElement>, index: number) => void;
|
||||
onProviderDragOver?: (event: DragEvent<HTMLElement>, index: number) => void;
|
||||
onProviderDrop?: (event: DragEvent<HTMLElement>, index: number) => void;
|
||||
onProviderDragEnd?: () => void;
|
||||
onToggleAutoFallback?: (enabled: boolean) => void;
|
||||
onToggleRememberCredentials?: (enabled: boolean) => void;
|
||||
onRoutingProviderChange?: (hosterId: string, provider: string) => void;
|
||||
onRoutingRemove?: (hosterId: string) => void;
|
||||
onRoutingAdd?: (hosterId: string) => void;
|
||||
}
|
||||
|
||||
export interface AccountWorkspaceProps {
|
||||
model: AccountWorkspaceViewModel;
|
||||
actions: AccountWorkspaceActions;
|
||||
}
|
||||
|
||||
export interface AccountDialogField {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "text" | "password" | "number" | "textarea";
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
}
|
||||
|
||||
export interface AccountAddDialogModel {
|
||||
open: boolean;
|
||||
query: string;
|
||||
filter: AccountAddFilter;
|
||||
options: readonly AccountAddOption[];
|
||||
selectedOptionId: string | null;
|
||||
fields: readonly AccountDialogField[];
|
||||
error: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface AccountAddDialogActions {
|
||||
onQueryChange: (value: string) => void;
|
||||
onFilterChange: (filter: AccountAddFilter) => void;
|
||||
onOptionSelect: (optionId: string) => void;
|
||||
onFieldChange: (fieldId: string, value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export interface AccountEditDialogModel {
|
||||
open: boolean;
|
||||
hoster: string;
|
||||
mode: string;
|
||||
identity: string;
|
||||
enabled: boolean;
|
||||
fields: readonly AccountDialogField[];
|
||||
error: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface AccountEditDialogActions {
|
||||
onFieldChange: (fieldId: string, value: string) => void;
|
||||
onClose: () => void;
|
||||
onCheck: () => void;
|
||||
onSave: () => void;
|
||||
onRemove: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
}
|
||||
|
||||
function AccountDialogFields({
|
||||
fields,
|
||||
onChange
|
||||
}: {
|
||||
fields: readonly AccountDialogField[];
|
||||
onChange: (fieldId: string, value: string) => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="settings-account-dialog-fields">
|
||||
{fields.map((field) => (
|
||||
<label className="settings-account-dialog-field" key={field.id}>
|
||||
<span>{field.label}</span>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
className="settings-control settings-account-dialog-textarea"
|
||||
onChange={(event) => onChange(field.id, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
value={field.value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
autoComplete={field.type === "password" ? "off" : undefined}
|
||||
className="settings-control"
|
||||
inputMode={field.type === "number" ? "decimal" : undefined}
|
||||
onChange={(event) => onChange(field.id, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
type={field.type}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
{field.help ? <span className="settings-field-help">{field.help}</span> : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({
|
||||
row,
|
||||
selected,
|
||||
busy,
|
||||
actions
|
||||
}: {
|
||||
row: AccountRowViewModel;
|
||||
selected: boolean;
|
||||
busy: boolean;
|
||||
actions: AccountWorkspaceActions;
|
||||
}): ReactElement {
|
||||
const selectRow = (): void => actions.onSelect(row.id);
|
||||
const onClick = (): void => selectRow();
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
selectRow();
|
||||
};
|
||||
const openContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
actions.onContextMenu(row.id, event.clientX, event.clientY);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
aria-selected={selected}
|
||||
className={`settings-account-row${selected ? " is-selected" : ""}${row.problem ? " has-problem" : ""}${!row.enabled ? " is-disabled" : ""}`}
|
||||
onClick={onClick}
|
||||
onContextMenu={openContextMenu}
|
||||
onDoubleClick={() => actions.onEdit(row.id)}
|
||||
onKeyDown={onKeyDown}
|
||||
role="row"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="settings-account-column-enable" role="cell">
|
||||
<input
|
||||
aria-label={`${row.hoster} ${row.enabled ? "deaktivieren" : "aktivieren"}`}
|
||||
checked={row.enabled}
|
||||
disabled={busy}
|
||||
onChange={() => actions.onToggleEnabled(row.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
type="checkbox"
|
||||
/>
|
||||
</span>
|
||||
<span className="settings-account-hoster" role="cell" title={`${row.hoster} · ${row.mode}`}>
|
||||
<img alt="" aria-hidden="true" draggable={false} height="20" src={row.icon} width="20" />
|
||||
<span>
|
||||
<strong>{row.hoster}</strong>
|
||||
<small>{row.mode}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span className="settings-account-status" role="cell">
|
||||
<span className={`settings-account-status-badge is-${row.status.tone}`}>{row.status.text}</span>
|
||||
</span>
|
||||
<span className="settings-account-traffic" role="cell">{row.traffic}</span>
|
||||
<span className="settings-account-username settings-copyable" role="cell" title={row.username}>{row.username}</span>
|
||||
<span className="settings-account-expires" role="cell">{row.expires}</span>
|
||||
<span className="settings-account-credential" role="cell">{row.credential}</span>
|
||||
<span className="settings-account-column-actions" role="cell">
|
||||
<button
|
||||
aria-label={`${row.hoster} Aktionen`}
|
||||
className="settings-account-action-button"
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
actions.onContextMenu(row.id, rect.right, rect.bottom);
|
||||
}}
|
||||
type="button"
|
||||
>⋯</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function syncAccountTableScroll(event: UIEvent<HTMLDivElement>): void {
|
||||
const header = event.currentTarget.parentElement?.querySelector<HTMLElement>(".settings-account-table-header");
|
||||
if (header) {
|
||||
header.scrollLeft = event.currentTarget.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
const selectedIds = new Set(model.selectedIds);
|
||||
return (
|
||||
<>
|
||||
<DataTable className="settings-account-table" label="Accounts">
|
||||
<DataTableHeader className="settings-account-table-header">
|
||||
<div className="settings-account-table-grid" role="row">
|
||||
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
|
||||
{ACCOUNT_COLUMNS.map((column) => (
|
||||
<span key={column} role="columnheader">
|
||||
{column === "Status" && actions.onStatusSort ? (
|
||||
<button className="settings-account-sort" onClick={actions.onStatusSort} type="button">
|
||||
{column}{model.statusSort === "desc" ? " ▼" : model.statusSort === "asc" ? " ▲" : ""}
|
||||
</button>
|
||||
) : column}
|
||||
</span>
|
||||
))}
|
||||
<span aria-label="Aktionen" className="settings-account-column-actions" role="columnheader" />
|
||||
</div>
|
||||
</DataTableHeader>
|
||||
<DataTableBody className="settings-account-table-body" data-visual-region="accounts-table-body" onScroll={syncAccountTableScroll}>
|
||||
{model.busy && model.rows.length === 0 ? (
|
||||
<DataTableEmpty description="Die Accountdaten werden aktualisiert." title="Accounts werden geladen" />
|
||||
) : model.error ? (
|
||||
<DataTableEmpty className="settings-account-table-error" description="Die gespeicherten Accounts bleiben unverändert." title={model.error} />
|
||||
) : model.rows.length === 0 ? (
|
||||
<DataTableEmpty description="Füge einen Account hinzu, um Downloads über einen Anbieter zu starten." title="Noch keine Accounts" />
|
||||
) : model.rows.map((row) => cloneElement(
|
||||
AccountRow({ actions, busy: model.busy, row, selected: selectedIds.has(row.id) }),
|
||||
{ key: row.id }
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
<div className="settings-account-local-actions">
|
||||
<div>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onAdd} type="button">+ Hinzufügen</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">− Entfernen</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheckAll} type="button">↻ Aktualisieren</button>
|
||||
</div>
|
||||
<span>{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
return (
|
||||
<div className="settings-account-rules">
|
||||
<section className="settings-rule-section">
|
||||
<h3>Provider-Reihenfolge</h3>
|
||||
<p>Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.</p>
|
||||
{model.rules.providerOrder.length === 0 ? (
|
||||
<span className="settings-rule-empty">Keine Provider konfiguriert.</span>
|
||||
) : (
|
||||
<ol className="settings-provider-order">
|
||||
{model.rules.providerOrder.map((provider, index) => (
|
||||
<li
|
||||
draggable={Boolean(actions.onProviderDragStart)}
|
||||
key={`${provider}-${index}`}
|
||||
onDragEnd={actions.onProviderDragEnd}
|
||||
onDragOver={(event) => actions.onProviderDragOver?.(event, index)}
|
||||
onDragStart={(event) => actions.onProviderDragStart?.(event, index)}
|
||||
onDrop={(event) => actions.onProviderDrop?.(event, index)}
|
||||
>
|
||||
<span>{provider}</span>
|
||||
{actions.onMoveProvider ? (
|
||||
<span className="settings-provider-order-actions">
|
||||
<button aria-label={`${provider} nach oben`} disabled={index === 0} onClick={() => actions.onMoveProvider?.(index, -1)} type="button">↑</button>
|
||||
<button aria-label={`${provider} nach unten`} disabled={index === model.rules.providerOrder.length - 1} onClick={() => actions.onMoveProvider?.(index, 1)} type="button">↓</button>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<label className="settings-rule-toggle">
|
||||
<input
|
||||
checked={model.rules.autoFallback}
|
||||
disabled={!actions.onToggleAutoFallback}
|
||||
onChange={(event) => actions.onToggleAutoFallback?.(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Automatischer Fallback</span>
|
||||
</label>
|
||||
{typeof model.rules.rememberCredentials === "boolean" ? (
|
||||
<label className="settings-rule-toggle">
|
||||
<input
|
||||
checked={model.rules.rememberCredentials}
|
||||
disabled={!actions.onToggleRememberCredentials}
|
||||
onChange={(event) => actions.onToggleRememberCredentials?.(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Zugangsdaten lokal speichern</span>
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="settings-rule-section">
|
||||
<h3>Hoster-Routing</h3>
|
||||
<p>Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.</p>
|
||||
{model.rules.routingEntries ? (
|
||||
<>
|
||||
{model.rules.routingEntries.length === 0 ? <span className="settings-rule-empty">Keine eigenen Zuordnungen.</span> : (
|
||||
<div className="settings-routing-editor">
|
||||
{model.rules.routingEntries.map((entry) => (
|
||||
<div className="settings-routing-editor-row" key={entry.hosterId}>
|
||||
<span>{entry.hosterLabel}</span>
|
||||
<select
|
||||
aria-label={`Provider für ${entry.hosterLabel}`}
|
||||
className="settings-control"
|
||||
onChange={(event) => actions.onRoutingProviderChange?.(entry.hosterId, event.target.value)}
|
||||
value={entry.provider}
|
||||
>
|
||||
{entry.providers.map((provider) => <option key={provider.value} value={provider.value}>{provider.label}</option>)}
|
||||
</select>
|
||||
<button aria-label={`${entry.hosterLabel} Zuordnung entfernen`} className="settings-button settings-button-danger" onClick={() => actions.onRoutingRemove?.(entry.hosterId)} type="button">Entfernen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{model.rules.availableRoutingHosters ? (
|
||||
<select
|
||||
aria-label="Hoster-Routing hinzufügen"
|
||||
className="settings-control settings-routing-add"
|
||||
onChange={(event) => {
|
||||
if (event.target.value) {
|
||||
actions.onRoutingAdd?.(event.target.value);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option disabled value="">Hoster hinzufügen…</option>
|
||||
{model.rules.availableRoutingHosters.map((hoster) => <option key={hoster.value} value={hoster.value}>{hoster.label}</option>)}
|
||||
<option value="__custom">Eigener Hoster…</option>
|
||||
</select>
|
||||
) : null}
|
||||
</>
|
||||
) : model.rules.routing.length === 0 ? (
|
||||
<span className="settings-rule-empty">Keine eigenen Zuordnungen.</span>
|
||||
) : (
|
||||
<ul className="settings-routing-list">
|
||||
{model.rules.routing.map((route, index) => <li className="settings-copyable" key={`${route}-${index}`}>{route}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{model.rules.rotationEvents ? (
|
||||
<section className="settings-rule-section">
|
||||
<h3>Rotations-Verlauf</h3>
|
||||
{model.rules.rotationEvents.length === 0 ? (
|
||||
<span className="settings-rule-empty">Noch keine Rotations-Ereignisse.</span>
|
||||
) : model.rules.rotationEvents.map((event) => (
|
||||
<div className="settings-rotation-event" key={event.id}>
|
||||
<strong>{event.title}</strong>
|
||||
<span>{event.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
return (
|
||||
<div className="settings-account-workspace">
|
||||
<header className="settings-account-heading">
|
||||
<div>
|
||||
<h2>Accountverwaltung</h2>
|
||||
<p>Accounts hinzufügen, prüfen und verwalten.</p>
|
||||
</div>
|
||||
{typeof model.allEnabled === "boolean" && actions.onSetAllEnabled ? (
|
||||
<label className="settings-rule-toggle settings-account-all-enabled">
|
||||
<input
|
||||
checked={model.allEnabled}
|
||||
disabled={model.busy || model.rows.length === 0}
|
||||
onChange={(event) => actions.onSetAllEnabled?.(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Accounts zum Herunterladen verwenden</span>
|
||||
</label>
|
||||
) : null}
|
||||
</header>
|
||||
<div aria-label="Accountverwaltung" className="settings-account-tabs" role="tablist">
|
||||
<button
|
||||
aria-controls="settings-account-overview"
|
||||
aria-selected={model.activePanel === "overview"}
|
||||
id="settings-account-overview-tab"
|
||||
onClick={() => actions.onPanelChange("overview")}
|
||||
role="tab"
|
||||
type="button"
|
||||
>Übersicht</button>
|
||||
<button
|
||||
aria-controls="settings-account-rules"
|
||||
aria-selected={model.activePanel === "rules"}
|
||||
id="settings-account-rules-tab"
|
||||
onClick={() => actions.onPanelChange("rules")}
|
||||
role="tab"
|
||||
type="button"
|
||||
>Verwendungsregeln</button>
|
||||
</div>
|
||||
<div
|
||||
aria-labelledby="settings-account-overview-tab"
|
||||
className="settings-account-panel"
|
||||
hidden={model.activePanel !== "overview"}
|
||||
id="settings-account-overview"
|
||||
role="tabpanel"
|
||||
>
|
||||
{AccountOverview({ actions, model })}
|
||||
</div>
|
||||
<div
|
||||
aria-labelledby="settings-account-rules-tab"
|
||||
className="settings-account-panel"
|
||||
hidden={model.activePanel !== "rules"}
|
||||
id="settings-account-rules"
|
||||
role="tabpanel"
|
||||
>
|
||||
{AccountRules({ actions, model })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountAddDialog({
|
||||
model,
|
||||
actions
|
||||
}: {
|
||||
model: AccountAddDialogModel;
|
||||
actions: AccountAddDialogActions;
|
||||
}): ReactElement | null {
|
||||
const onFilterChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
actions.onFilterChange(event.target.value as AccountAddFilter);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
actions={(
|
||||
<>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onClose} type="button">Abbrechen</button>
|
||||
<button className="settings-button settings-button-primary" disabled={model.busy || !model.selectedOptionId} onClick={actions.onSubmit} type="button">Prüfen und speichern</button>
|
||||
</>
|
||||
)}
|
||||
actionsClassName="settings-account-dialog-actions"
|
||||
bodyClassName="settings-account-dialog-body"
|
||||
description="Wähle einen Dienst und trage die passenden Zugangsdaten ein."
|
||||
onClose={actions.onClose}
|
||||
open={model.open}
|
||||
size="account"
|
||||
title="Account hinzufügen"
|
||||
>
|
||||
<div className="settings-account-picker-controls">
|
||||
<input
|
||||
aria-label="Accounts durchsuchen"
|
||||
className="settings-control"
|
||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||
placeholder="Dienst oder Zugangstyp suchen"
|
||||
type="search"
|
||||
value={model.query}
|
||||
/>
|
||||
<select aria-label="Account-Typ filtern" className="settings-control" onChange={onFilterChange} value={model.filter}>
|
||||
<option value="all">Alle</option>
|
||||
<option value="api">API</option>
|
||||
<option value="web">Web</option>
|
||||
</select>
|
||||
</div>
|
||||
<div aria-label="Verfügbare Account-Typen" className="settings-account-picker" role="listbox">
|
||||
{model.options.length === 0 ? (
|
||||
<span className="settings-account-picker-empty">Keine passenden Account-Typen.</span>
|
||||
) : model.options.map((option) => {
|
||||
const selected = option.id === model.selectedOptionId;
|
||||
return (
|
||||
<div className="settings-account-picker-entry" key={option.id}>
|
||||
<button
|
||||
aria-selected={selected}
|
||||
className={`settings-account-picker-row${selected ? " is-selected" : ""}`}
|
||||
onClick={() => actions.onOptionSelect(option.id)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
<strong>{option.title}</strong>
|
||||
<small>{option.description}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{option.mode}</strong>
|
||||
<small>{option.functionLabel}</small>
|
||||
</span>
|
||||
</button>
|
||||
{selected ? <AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} /> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountEditDialog({
|
||||
model,
|
||||
actions
|
||||
}: {
|
||||
model: AccountEditDialogModel;
|
||||
actions: AccountEditDialogActions;
|
||||
}): ReactElement | null {
|
||||
return (
|
||||
<Dialog
|
||||
actions={(
|
||||
<>
|
||||
<button className="settings-button settings-button-danger" disabled={model.busy} onClick={actions.onRemove} type="button">Entfernen</button>
|
||||
<span className="settings-account-dialog-action-spacer" />
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onClose} type="button">Abbrechen</button>
|
||||
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheck} type="button">Prüfen</button>
|
||||
<button className="settings-button settings-button-primary" disabled={model.busy} onClick={actions.onSave} type="button">Speichern</button>
|
||||
</>
|
||||
)}
|
||||
actionsClassName="settings-account-dialog-actions"
|
||||
bodyClassName="settings-account-dialog-body"
|
||||
description="Bearbeite ausschließlich den ausgewählten Account."
|
||||
onClose={actions.onClose}
|
||||
open={model.open}
|
||||
size="account"
|
||||
title="Account bearbeiten"
|
||||
>
|
||||
<div className="settings-account-edit-identity">
|
||||
<span>{model.hoster} · {model.mode}</span>
|
||||
<strong className="settings-copyable">{model.identity}</strong>
|
||||
</div>
|
||||
<label className="settings-rule-toggle settings-account-edit-enabled">
|
||||
<input checked={model.enabled} disabled={model.busy} onChange={actions.onToggleEnabled} type="checkbox" />
|
||||
<span>Account aktiviert</span>
|
||||
</label>
|
||||
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
||||
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { cloneElement, type ChangeEvent, type ReactElement } from "react";
|
||||
import type {
|
||||
SettingsFieldViewModel,
|
||||
SettingsFormViewModel,
|
||||
SettingsTextFieldViewModel
|
||||
} from "./settings-model";
|
||||
|
||||
export interface SettingsFormActions {
|
||||
onChange: (fieldId: string, value: string | boolean) => void;
|
||||
onAction: (fieldId: string) => void;
|
||||
onCommit?: (fieldId: string, value: string) => void;
|
||||
}
|
||||
|
||||
export interface SettingsFormProps {
|
||||
model: SettingsFormViewModel;
|
||||
actions: SettingsFormActions;
|
||||
}
|
||||
|
||||
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
|
||||
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
|
||||
}
|
||||
|
||||
function TextControl({ field, actions }: { field: SettingsTextFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||
const describedBy = field.help ? `${field.id}-help` : undefined;
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
actions.onChange(field.id, event.target.value);
|
||||
};
|
||||
const control = field.kind === "textarea" ? (
|
||||
<textarea
|
||||
aria-describedby={describedBy}
|
||||
className="settings-control settings-textarea"
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
onChange={onChange}
|
||||
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
value={field.value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-describedby={describedBy}
|
||||
className={`settings-control${field.kind === "path" ? " settings-copyable" : ""}`}
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
inputMode={field.inputMode}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
onChange={onChange}
|
||||
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
|
||||
placeholder={field.placeholder}
|
||||
step={field.step}
|
||||
type={field.kind === "number" ? "number" : "text"}
|
||||
value={field.value}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label htmlFor={field.id}>{field.label}</label>
|
||||
{field.actionLabel ? (
|
||||
<div className="settings-control-row">
|
||||
{control}
|
||||
<button
|
||||
className="settings-button settings-button-secondary"
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onAction(field.id)}
|
||||
type="button"
|
||||
>{field.actionLabel}</button>
|
||||
</div>
|
||||
) : control}
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsField({ field, actions }: { field: SettingsFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||
if (field.kind === "text" || field.kind === "path" || field.kind === "number" || field.kind === "textarea") {
|
||||
return <TextControl actions={actions} field={field} />;
|
||||
}
|
||||
if (field.kind === "select") {
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label htmlFor={field.id}>{field.label}</label>
|
||||
<select
|
||||
aria-describedby={field.help ? `${field.id}-help` : undefined}
|
||||
className="settings-control"
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
onChange={(event) => actions.onChange(field.id, event.target.value)}
|
||||
value={field.value}
|
||||
>
|
||||
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.kind === "theme") {
|
||||
return (
|
||||
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
|
||||
<legend>{field.label}</legend>
|
||||
<div aria-describedby={field.help ? `${field.id}-help` : undefined} className="settings-theme-options" role="radiogroup">
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
aria-checked={field.value === option.value}
|
||||
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
|
||||
key={option.value}
|
||||
onClick={() => actions.onChange(field.id, option.value)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FieldHelp field={field} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
if (field.kind === "switch") {
|
||||
return (
|
||||
<div className="settings-field settings-switch-field">
|
||||
<div>
|
||||
<span className="settings-switch-label" id={`${field.id}-label`}>{field.label}</span>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
<button
|
||||
aria-checked={field.value}
|
||||
aria-describedby={field.help ? `${field.id}-help` : undefined}
|
||||
aria-labelledby={`${field.id}-label`}
|
||||
className={`settings-switch${field.value ? " is-on" : ""}`}
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onChange(field.id, !field.value)}
|
||||
role="switch"
|
||||
type="button"
|
||||
><span /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-field settings-action-field">
|
||||
<div>
|
||||
<span className="settings-switch-label">{field.label}</span>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
<button
|
||||
className="settings-button settings-button-secondary"
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onAction(field.id)}
|
||||
type="button"
|
||||
>{field.actionLabel}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsForm({ model, actions }: SettingsFormProps): ReactElement {
|
||||
return (
|
||||
<div className="settings-form-column">
|
||||
<header className="settings-form-heading">
|
||||
<h2>{model.title}</h2>
|
||||
<p>{model.description}</p>
|
||||
</header>
|
||||
{model.groups.map((group) => (
|
||||
<section className="settings-form-group" key={group.id}>
|
||||
<header>
|
||||
<h3>{group.title}</h3>
|
||||
{group.description ? <p>{group.description}</p> : null}
|
||||
</header>
|
||||
<div className="settings-form-fields">
|
||||
{group.fields.map((field) => cloneElement(SettingsField({ actions, field }), { key: field.id }))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
SETTINGS_SECTIONS,
|
||||
getSettingsSaveLabel,
|
||||
type SettingsFormViewModel,
|
||||
type SettingsSaveState,
|
||||
type SettingsSection
|
||||
} from "./settings-model";
|
||||
import {
|
||||
AccountWorkspace,
|
||||
type AccountWorkspaceActions,
|
||||
type AccountWorkspaceViewModel
|
||||
} from "./AccountWorkspace";
|
||||
import { SettingsForm, type SettingsFormActions } from "./SettingsForm";
|
||||
import "./settings.css";
|
||||
|
||||
export type SettingsViewRegion = "all" | "sidebar" | "content";
|
||||
|
||||
export interface SettingsViewModel {
|
||||
section: SettingsSection;
|
||||
saveState: SettingsSaveState;
|
||||
form: SettingsFormViewModel;
|
||||
accounts: AccountWorkspaceViewModel;
|
||||
}
|
||||
|
||||
export interface SettingsViewActions {
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
onSave: () => void;
|
||||
form: SettingsFormActions;
|
||||
accounts: AccountWorkspaceActions;
|
||||
}
|
||||
|
||||
export interface SettingsViewProps {
|
||||
model: SettingsViewModel;
|
||||
actions: SettingsViewActions;
|
||||
region?: SettingsViewRegion;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactElement {
|
||||
return (
|
||||
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
|
||||
<div className="settings-sidebar-heading">
|
||||
<strong>Einstellungen</strong>
|
||||
</div>
|
||||
<div className="settings-sidebar-list">
|
||||
{SETTINGS_SECTIONS.map((section) => (
|
||||
<button
|
||||
aria-current={model.section === section.id ? "page" : undefined}
|
||||
className={`settings-sidebar-item${model.section === section.id ? " is-active" : ""}`}
|
||||
key={section.id}
|
||||
onClick={() => actions.onSectionChange(section.id)}
|
||||
type="button"
|
||||
>{section.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
|
||||
const saveLabel = getSettingsSaveLabel(model.saveState);
|
||||
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
|
||||
return (
|
||||
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
|
||||
<header className="settings-content-header">
|
||||
<div>
|
||||
<h1>Einstellungen</h1>
|
||||
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
className="settings-button settings-button-primary"
|
||||
disabled={saveDisabled}
|
||||
onClick={actions.onSave}
|
||||
type="button"
|
||||
>Einstellungen speichern</button>
|
||||
</header>
|
||||
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
|
||||
{model.section === "accounts"
|
||||
? <AccountWorkspace actions={actions.accounts} model={model.accounts} />
|
||||
: <SettingsForm actions={actions.form} model={model.form} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsView({ model, actions, region = "all" }: SettingsViewProps): ReactElement {
|
||||
if (region === "sidebar") {
|
||||
return <SettingsSidebar actions={actions} model={model} />;
|
||||
}
|
||||
if (region === "content") {
|
||||
return <SettingsContent actions={actions} model={model} />;
|
||||
}
|
||||
return (
|
||||
<div className="settings-view">
|
||||
<SettingsSidebar actions={actions} model={model} />
|
||||
<SettingsContent actions={actions} model={model} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
import type { AppSettings } from "../../../shared/types";
|
||||
import type { AccountService } from "../../account-edit";
|
||||
import { resolveAccountUsername } from "../../account-ui";
|
||||
import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
|
||||
|
||||
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
|
||||
export type SettingsSaveState = "clean" | "dirty" | "saving" | "saved" | "error";
|
||||
|
||||
export const SETTINGS_SECTIONS: readonly { id: SettingsSection; label: string }[] = [
|
||||
{ id: "allgemein", label: "Allgemein" },
|
||||
{ id: "accounts", label: "Accounts" },
|
||||
{ id: "extract", label: "Entpacken" },
|
||||
{ id: "speed", label: "Geschwindigkeit" },
|
||||
{ id: "cleanup", label: "Bereinigung" },
|
||||
{ id: "updates", label: "Updates" }
|
||||
];
|
||||
|
||||
export const ACCOUNT_COLUMNS = [
|
||||
"Hoster",
|
||||
"Status",
|
||||
"Download-Traffic übrig",
|
||||
"Benutzername",
|
||||
"Verfallsdatum",
|
||||
"Passwort/Zugang"
|
||||
] as const;
|
||||
|
||||
export function getSettingsSaveLabel(state: SettingsSaveState): string {
|
||||
switch (state) {
|
||||
case "dirty":
|
||||
return "Ungespeicherte Änderungen";
|
||||
case "saving":
|
||||
return "Wird gespeichert…";
|
||||
case "error":
|
||||
return "Speichern fehlgeschlagen";
|
||||
case "clean":
|
||||
case "saved":
|
||||
return "Gespeichert";
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountStatusSourceState = "premium" | "free" | "invalid" | "checking" | "unchecked" | "disabled";
|
||||
export type AccountStatusTone = "ok" | "free" | "invalid" | "unknown" | "disabled";
|
||||
|
||||
export interface AccountRowSource {
|
||||
identityId: string;
|
||||
service: AccountService;
|
||||
hoster: string;
|
||||
mode: string;
|
||||
icon?: string;
|
||||
enabled: boolean;
|
||||
status: {
|
||||
state: AccountStatusSourceState;
|
||||
message: string;
|
||||
premiumUntilMs: number | null;
|
||||
email?: string;
|
||||
};
|
||||
dailyLimitBytes?: number;
|
||||
dailyUsageBytes?: number;
|
||||
username: string;
|
||||
credentialKind: "password" | "api-key" | "protected";
|
||||
canCheck: boolean;
|
||||
}
|
||||
|
||||
export interface AccountRowViewModel {
|
||||
id: string;
|
||||
service: AccountService;
|
||||
hoster: string;
|
||||
mode: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
selected: boolean;
|
||||
status: {
|
||||
tone: AccountStatusTone;
|
||||
text: string;
|
||||
};
|
||||
traffic: string;
|
||||
username: string;
|
||||
expires: string;
|
||||
credential: string;
|
||||
canCheck: boolean;
|
||||
problem: boolean;
|
||||
premiumUntilMs: number | null;
|
||||
}
|
||||
|
||||
export type AccountAddFilter = "all" | "api" | "web";
|
||||
|
||||
export interface AccountAddOption {
|
||||
id: string;
|
||||
service: AccountService;
|
||||
title: string;
|
||||
mode: string;
|
||||
description: string;
|
||||
functionLabel: string;
|
||||
filter: Exclude<AccountAddFilter, "all">;
|
||||
multi: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface AccountAddDraft {
|
||||
selectedId: string | null;
|
||||
login: string;
|
||||
password: string;
|
||||
token: string;
|
||||
dailyLimitGb: string;
|
||||
}
|
||||
|
||||
export interface TargetedAccountCheck {
|
||||
service: "megadebrid-api" | "debridlink";
|
||||
expectedStatusId: string;
|
||||
}
|
||||
|
||||
export interface SettingsFieldBase {
|
||||
id: string;
|
||||
label: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsTextFieldViewModel extends SettingsFieldBase {
|
||||
kind: "text" | "path" | "number" | "textarea";
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
inputMode?: "decimal" | "numeric" | "text" | "url";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
actionLabel?: string;
|
||||
commitOnBlur?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsSelectFieldViewModel extends SettingsFieldBase {
|
||||
kind: "select";
|
||||
value: string;
|
||||
options: readonly { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface SettingsSwitchFieldViewModel extends SettingsFieldBase {
|
||||
kind: "switch";
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsThemeFieldViewModel extends SettingsFieldBase {
|
||||
kind: "theme";
|
||||
value: string;
|
||||
options: readonly { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface SettingsActionFieldViewModel extends SettingsFieldBase {
|
||||
kind: "action";
|
||||
actionLabel: string;
|
||||
}
|
||||
|
||||
export type SettingsFieldViewModel =
|
||||
| SettingsTextFieldViewModel
|
||||
| SettingsSelectFieldViewModel
|
||||
| SettingsSwitchFieldViewModel
|
||||
| SettingsThemeFieldViewModel
|
||||
| SettingsActionFieldViewModel;
|
||||
|
||||
export interface SettingsFormGroupViewModel {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
fields: readonly SettingsFieldViewModel[];
|
||||
}
|
||||
|
||||
export interface SettingsFormViewModel {
|
||||
title: string;
|
||||
description: string;
|
||||
groups: readonly SettingsFormGroupViewModel[];
|
||||
}
|
||||
|
||||
export interface SettingsFormProjectionInput {
|
||||
settings: AppSettings;
|
||||
section: SettingsSection;
|
||||
speedLimitInput: string;
|
||||
scheduleSpeedInputs: Readonly<Record<string, string>>;
|
||||
themeChoice?: "light" | "dark" | "system";
|
||||
}
|
||||
|
||||
export function buildSettingsFormViewModel({
|
||||
settings,
|
||||
section,
|
||||
speedLimitInput,
|
||||
scheduleSpeedInputs,
|
||||
themeChoice = settings.theme
|
||||
}: SettingsFormProjectionInput): SettingsFormViewModel {
|
||||
if (section === "extract") {
|
||||
return {
|
||||
title: "Entpacken",
|
||||
description: "Ablauf, Ziel, Tonspur, Ablageform und Leistung.",
|
||||
groups: [
|
||||
{
|
||||
id: "extract-target",
|
||||
title: "Ziel und Ablauf",
|
||||
fields: [
|
||||
{ id: "extractDir", kind: "path", label: "Entpacken nach", value: settings.extractDir, actionLabel: "Wählen", help: "Zielordner für entpackte Dateien." },
|
||||
{ id: "autoExtract", kind: "switch", label: "Automatisch entpacken", value: settings.autoExtract },
|
||||
{ id: "autoSkipExtracted", kind: "switch", label: "Bereits Entpacktes überspringen", value: settings.autoSkipExtracted },
|
||||
{ id: "hideExtractedItems", kind: "switch", label: "Entpackte Einträge ausblenden", value: settings.hideExtractedItems },
|
||||
{ id: "autoExtractWhenStopped", kind: "switch", label: "Entpacken auch ohne laufende Sitzung", value: settings.autoExtractWhenStopped }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "extract-audio",
|
||||
title: "Deutsche Tonspur",
|
||||
fields: [
|
||||
{ id: "keepGermanAudioOnly", kind: "switch", label: "Nur deutsche Tonspur behalten", value: settings.keepGermanAudioOnly, help: "Benötigt ffmpeg." },
|
||||
{
|
||||
id: "germanAudioMode",
|
||||
kind: "select",
|
||||
label: "Welche Tonspur behalten",
|
||||
value: settings.germanAudioMode,
|
||||
disabled: !settings.keepGermanAudioOnly,
|
||||
options: [
|
||||
{ value: "tag", label: "Deutsche Spur per Sprach-Tag" },
|
||||
{ value: "first", label: "Immer erste Tonspur" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "extract-layout",
|
||||
title: "Ablageform",
|
||||
fields: [
|
||||
{ id: "autoRename4sf4sj", kind: "switch", label: "Automatisch umbenennen", value: settings.autoRename4sf4sj },
|
||||
{ id: "createExtractSubfolder", kind: "switch", label: "In Paket-Unterordner ablegen", value: settings.createExtractSubfolder },
|
||||
{ id: "collectMkvToLibrary", kind: "switch", label: "Videos in Sammelordner verschieben", value: settings.collectMkvToLibrary },
|
||||
{ id: "mkvLibraryDir", kind: "path", label: "Video-Sammelordner", value: settings.mkvLibraryDir, disabled: !settings.collectMkvToLibrary, actionLabel: "Wählen" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "extract-performance",
|
||||
title: "Leistung",
|
||||
fields: [
|
||||
{ id: "hybridExtract", kind: "switch", label: "Hybrid-Entpacken", value: settings.hybridExtract },
|
||||
{ id: "maxParallelExtract", kind: "number", label: "Gleichzeitige Entpackungen", value: String(settings.maxParallelExtract), min: 1, max: 8 },
|
||||
{
|
||||
id: "extractCpuPriority",
|
||||
kind: "select",
|
||||
label: "CPU-Priorität beim Entpacken",
|
||||
value: settings.extractCpuPriority,
|
||||
options: [
|
||||
{ value: "high", label: "Hoch (80% CPU)" },
|
||||
{ value: "middle", label: "Mittel (50% CPU)" },
|
||||
{ value: "low", label: "Niedrig (25% CPU)" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "extract-passwords",
|
||||
title: "Passwörter",
|
||||
fields: [
|
||||
{ id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: "Ein Passwort pro Zeile" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
if (section === "speed") {
|
||||
const scheduleGroups: SettingsFormGroupViewModel[] = (settings.bandwidthSchedules || []).map((schedule, index) => {
|
||||
const scheduleKey = schedule.id || `schedule-${index}`;
|
||||
return {
|
||||
id: `schedule:${index}`,
|
||||
title: `Zeitregel ${index + 1}`,
|
||||
fields: [
|
||||
{ id: `schedule:${index}:startHour`, kind: "number", label: "Von (Stunde)", value: String(schedule.startHour), min: 0, max: 23 },
|
||||
{ id: `schedule:${index}:endHour`, kind: "number", label: "Bis (Stunde)", value: String(schedule.endHour), min: 0, max: 23 },
|
||||
{
|
||||
id: `schedule:${index}:speedLimitMbps`,
|
||||
kind: "number",
|
||||
label: "Limit (MB/s)",
|
||||
value: scheduleSpeedInputs[scheduleKey] ?? String(schedule.speedLimitKbps / 1024),
|
||||
min: 0,
|
||||
step: 0.1,
|
||||
commitOnBlur: true
|
||||
},
|
||||
{ id: `schedule:${index}:enabled`, kind: "switch", label: "Zeitregel aktiviert", value: schedule.enabled },
|
||||
{ id: `schedule:${index}:remove`, kind: "action", label: "Zeitregel entfernen", actionLabel: "Entfernen" }
|
||||
]
|
||||
};
|
||||
});
|
||||
return {
|
||||
title: "Geschwindigkeit",
|
||||
description: "Tempo, Wiederverbindung und zeitgesteuerte Bandbreitenregeln.",
|
||||
groups: [
|
||||
{
|
||||
id: "speed-limit",
|
||||
title: "Tempo-Begrenzung",
|
||||
fields: [
|
||||
{ id: "speedLimitEnabled", kind: "switch", label: "Geschwindigkeit begrenzen", value: settings.speedLimitEnabled },
|
||||
{ id: "speedLimitInput", kind: "number", label: "Höchstgeschwindigkeit (MB/s)", value: speedLimitInput, min: 0, step: 0.1, disabled: !settings.speedLimitEnabled, commitOnBlur: true },
|
||||
{
|
||||
id: "speedLimitMode",
|
||||
kind: "select",
|
||||
label: "Limit gilt für",
|
||||
value: settings.speedLimitMode,
|
||||
disabled: !settings.speedLimitEnabled,
|
||||
options: [
|
||||
{ value: "global", label: "Global" },
|
||||
{ value: "per_download", label: "Pro Download" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "speed-connection",
|
||||
title: "Verbindung",
|
||||
fields: [
|
||||
{ id: "autoReconnect", kind: "switch", label: "Automatisch neu verbinden", value: settings.autoReconnect },
|
||||
{ id: "reconnectWaitSeconds", kind: "number", label: "Wartezeit vor neuem Versuch (Sek.)", value: String(settings.reconnectWaitSeconds), min: 10, max: 600 }
|
||||
]
|
||||
},
|
||||
...scheduleGroups,
|
||||
{
|
||||
id: "speed-schedule-add",
|
||||
title: "Bandbreitenplanung",
|
||||
description: "Jede Regel legt für ein Zeitfenster ein eigenes Limit fest.",
|
||||
fields: [{ id: "schedule:add", kind: "action", label: "Weitere Zeitregel", actionLabel: "Zeitregel hinzufügen" }]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
if (section === "cleanup") {
|
||||
return {
|
||||
title: "Bereinigung",
|
||||
description: "Integritätsprüfung und Aufräumen nach Downloads und Entpacken.",
|
||||
groups: [
|
||||
{
|
||||
id: "cleanup-check",
|
||||
title: "Prüfung",
|
||||
fields: [{ id: "enableIntegrityCheck", kind: "switch", label: "Dateien auf Fehler prüfen", value: settings.enableIntegrityCheck }]
|
||||
},
|
||||
{
|
||||
id: "cleanup-extract",
|
||||
title: "Nach dem Entpacken",
|
||||
fields: [
|
||||
{ id: "removeLinkFilesAfterExtract", kind: "switch", label: "Link-Dateien danach entfernen", value: settings.removeLinkFilesAfterExtract },
|
||||
{ id: "removeSamplesAfterExtract", kind: "switch", label: "Vorschau-Dateien danach entfernen", value: settings.removeSamplesAfterExtract },
|
||||
{
|
||||
id: "cleanupMode",
|
||||
kind: "select",
|
||||
label: "Archive nach dem Entpacken",
|
||||
value: settings.cleanupMode,
|
||||
options: [
|
||||
{ value: "none", label: "Keine Archive löschen" },
|
||||
{ value: "trash", label: "Archive in Papierkorb" },
|
||||
{ value: "delete", label: "Archive löschen" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "cleanup-finished",
|
||||
title: "Fertige Downloads und Konflikte",
|
||||
fields: [
|
||||
{
|
||||
id: "completedCleanupPolicy",
|
||||
kind: "select",
|
||||
label: "Fertige Downloads aus der Liste",
|
||||
value: settings.completedCleanupPolicy,
|
||||
options: [
|
||||
{ value: "never", label: "Nie" },
|
||||
{ value: "immediate", label: "Sofort" },
|
||||
{ value: "on_start", label: "Beim App-Start" },
|
||||
{ value: "package_done", label: "Sobald Paket fertig ist" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "extractConflictMode",
|
||||
kind: "select",
|
||||
label: "Bei gleichnamigen Dateien",
|
||||
value: settings.extractConflictMode,
|
||||
options: [
|
||||
{ value: "overwrite", label: "Überschreiben" },
|
||||
{ value: "skip", label: "Überspringen" },
|
||||
{ value: "rename", label: "Umbenennen" },
|
||||
{ value: "ask", label: "Nachfragen" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
if (section === "updates") {
|
||||
return {
|
||||
title: "Updates",
|
||||
description: "Quelle und Zeitpunkt der Update-Prüfung.",
|
||||
groups: [
|
||||
{
|
||||
id: "updates-main",
|
||||
title: "Aktualisierung",
|
||||
fields: [
|
||||
{ id: "autoUpdateCheck", kind: "switch", label: "Beim Start nach Updates suchen", value: settings.autoUpdateCheck },
|
||||
{ id: "updateRepo", kind: "text", label: "Update-Quelle", value: settings.updateRepo, help: "Quelle im Format Benutzer/Repository." },
|
||||
{ id: "update:check", kind: "action", label: "Jetzt nach einer neuen Version suchen", actionLabel: "Nach Updates suchen" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
if (section === "accounts") {
|
||||
return { title: "Accounts", description: "Accounts und Verwendungsregeln.", groups: [] };
|
||||
}
|
||||
return {
|
||||
title: "Allgemein",
|
||||
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
|
||||
groups: [
|
||||
{
|
||||
id: "general-storage",
|
||||
title: "Speicherort",
|
||||
fields: [
|
||||
{ id: "outputDir", kind: "path", label: "Download-Ordner", value: settings.outputDir, actionLabel: "Wählen", help: "Zielordner für heruntergeladene Dateien." },
|
||||
{ id: "packageName", kind: "text", label: "Paketname (optional)", value: settings.packageName }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "general-downloads",
|
||||
title: "Download-Verhalten",
|
||||
fields: [
|
||||
{ id: "maxParallel", kind: "number", label: "Max. gleichzeitige Downloads", value: String(settings.maxParallel), min: 1, max: 50 },
|
||||
{ id: "retryLimit", kind: "number", label: "Automatische Wiederholungen", value: String(settings.retryLimit), min: 0, max: 99 },
|
||||
{ id: "autoResumeOnStart", kind: "switch", label: "Beim Start automatisch fortsetzen", value: settings.autoResumeOnStart },
|
||||
{ id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "general-history",
|
||||
title: "Verlauf",
|
||||
fields: [
|
||||
{
|
||||
id: "historyRetentionMode",
|
||||
kind: "select",
|
||||
label: "Verlauf speichern",
|
||||
value: settings.historyRetentionMode,
|
||||
options: [
|
||||
{ value: "never", label: "Nie" },
|
||||
{ value: "session", label: "Nur aktuelle Session" },
|
||||
{ value: "permanent", label: "Dauerhaft" }
|
||||
]
|
||||
},
|
||||
{ id: "historyMaxEntries", kind: "number", label: "Maximale Verlauf-Einträge", value: String(settings.historyMaxEntries), min: 50, max: 100000, disabled: settings.historyRetentionMode !== "permanent" },
|
||||
{ id: "historyMaxAgeDays", kind: "number", label: "Einträge löschen älter als (Tage)", value: String(settings.historyMaxAgeDays), min: 0, max: 3650, disabled: settings.historyRetentionMode !== "permanent" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "general-interface",
|
||||
title: "Oberfläche und Bedienung",
|
||||
fields: [
|
||||
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
|
||||
{ id: "autoSortPackagesByProgress", kind: "switch", label: "Nach Fortschritt sortieren", value: settings.autoSortPackagesByProgress },
|
||||
{ id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray },
|
||||
{ id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
|
||||
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
|
||||
{ id: "backupIncludeRemoteDiagnostics", kind: "switch", label: "Ferndiagnose-Einstellungen mitsichern", value: settings.backupIncludeRemoteDiagnostics },
|
||||
{
|
||||
id: "theme",
|
||||
kind: "theme",
|
||||
label: "Theme",
|
||||
value: themeChoice,
|
||||
options: [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "system", label: "System" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "general-notifications",
|
||||
title: "Discord-Benachrichtigungen",
|
||||
fields: [
|
||||
{ id: "notifyUrl", kind: "text", label: "Webhook-Adresse", value: settings.notifyUrl, placeholder: "https://discord.com/api/webhooks/…", actionLabel: "Testen" },
|
||||
{ id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention },
|
||||
{ id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted },
|
||||
{ id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed },
|
||||
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn alles fertig ist", value: settings.notifyOnRunFinished }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAccountRowId(service: AccountService, mode: string, identityId: string): string {
|
||||
return [service, mode, identityId].map((part) => encodeURIComponent(part)).join("::");
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / (1024 ** unitIndex);
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatTraffic(limitBytes?: number, usageBytes?: number): string {
|
||||
if (!Number.isFinite(limitBytes) || !limitBytes || limitBytes <= 0) {
|
||||
return "Unbeschränkt";
|
||||
}
|
||||
const safeUsage = Number.isFinite(usageBytes) && usageBytes && usageBytes > 0 ? usageBytes : 0;
|
||||
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig`;
|
||||
}
|
||||
|
||||
function formatExpiry(premiumUntilMs: number | null): string {
|
||||
if (!premiumUntilMs || !Number.isFinite(premiumUntilMs) || premiumUntilMs <= 0) {
|
||||
return "—";
|
||||
}
|
||||
return new Intl.DateTimeFormat("de-DE").format(new Date(premiumUntilMs));
|
||||
}
|
||||
|
||||
function projectStatus(source: AccountRowSource): { tone: AccountStatusTone; text: string } {
|
||||
if (!source.enabled || source.status.state === "disabled") {
|
||||
return { tone: "disabled", text: "Deaktiviert" };
|
||||
}
|
||||
switch (source.status.state) {
|
||||
case "premium":
|
||||
return { tone: "ok", text: source.status.message.trim() || "Premium Account" };
|
||||
case "free":
|
||||
return { tone: "free", text: "Free Account" };
|
||||
case "invalid":
|
||||
return { tone: "invalid", text: source.status.message.trim() || "Zugang ungültig" };
|
||||
case "checking":
|
||||
return { tone: "unknown", text: "Prüft…" };
|
||||
case "unchecked":
|
||||
return { tone: "unknown", text: "Noch nicht geprüft" };
|
||||
}
|
||||
}
|
||||
|
||||
function projectCredential(kind: AccountRowSource["credentialKind"]): string {
|
||||
if (kind === "api-key") {
|
||||
return "API-Key";
|
||||
}
|
||||
return kind === "password" ? "••••••" : "Geschützter Zugang";
|
||||
}
|
||||
|
||||
export function projectAccountRows(
|
||||
sources: readonly AccountRowSource[],
|
||||
selectedIds: readonly string[],
|
||||
nowMs: number = Date.now()
|
||||
): AccountRowViewModel[] {
|
||||
const selected = new Set(selectedIds);
|
||||
return sources.map((source) => {
|
||||
const id = buildAccountRowId(source.service, source.mode, source.identityId);
|
||||
const status = projectStatus(source);
|
||||
const premiumUntilMs = source.status.premiumUntilMs && source.status.premiumUntilMs > nowMs
|
||||
? source.status.premiumUntilMs
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
service: source.service,
|
||||
hoster: source.hoster,
|
||||
mode: source.mode,
|
||||
icon: ACCOUNT_SERVICE_ICONS[source.service],
|
||||
enabled: source.enabled,
|
||||
selected: selected.has(id),
|
||||
status,
|
||||
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
|
||||
username: resolveAccountUsername(source.username, source.status.email),
|
||||
expires: formatExpiry(source.status.premiumUntilMs),
|
||||
credential: projectCredential(source.credentialKind),
|
||||
canCheck: source.canCheck,
|
||||
problem: status.tone === "invalid",
|
||||
premiumUntilMs
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function sortAccountRows(
|
||||
rows: readonly AccountRowViewModel[],
|
||||
direction: "desc" | "asc" = "desc"
|
||||
): AccountRowViewModel[] {
|
||||
return rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.sort((left, right) => {
|
||||
const leftUntil = left.row.premiumUntilMs ?? -1;
|
||||
const rightUntil = right.row.premiumUntilMs ?? -1;
|
||||
if (leftUntil > 0 && rightUntil > 0) {
|
||||
return (direction === "desc" ? rightUntil - leftUntil : leftUntil - rightUntil) || left.index - right.index;
|
||||
}
|
||||
if (leftUntil > 0) {
|
||||
return -1;
|
||||
}
|
||||
if (rightUntil > 0) {
|
||||
return 1;
|
||||
}
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map(({ row }) => row);
|
||||
}
|
||||
|
||||
export function pruneAccountSelection(
|
||||
selectedIds: readonly string[],
|
||||
rows: readonly Pick<AccountRowViewModel, "id">[]
|
||||
): string[] {
|
||||
const existing = new Set(rows.map((row) => row.id));
|
||||
return [...new Set(selectedIds)].filter((id) => existing.has(id));
|
||||
}
|
||||
|
||||
export function filterAccountAddOptions(
|
||||
options: readonly AccountAddOption[],
|
||||
query: string,
|
||||
filter: AccountAddFilter,
|
||||
configuredServices: readonly string[]
|
||||
): AccountAddOption[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("de-DE");
|
||||
const configured = new Set(configuredServices);
|
||||
return options.filter((option) => {
|
||||
if (!option.multi && configured.has(option.service)) {
|
||||
return false;
|
||||
}
|
||||
if (filter !== "all" && option.filter !== filter) {
|
||||
return false;
|
||||
}
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
return [option.title, option.service, option.mode, option.description, option.functionLabel]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("de-DE")
|
||||
.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
|
||||
export function reconcileAccountAddDraft(
|
||||
draft: AccountAddDraft,
|
||||
visibleOptions: readonly Pick<AccountAddOption, "id">[]
|
||||
): AccountAddDraft {
|
||||
if (draft.selectedId && visibleOptions.some((option) => option.id === draft.selectedId)) {
|
||||
return draft;
|
||||
}
|
||||
return {
|
||||
selectedId: null,
|
||||
login: "",
|
||||
password: "",
|
||||
token: "",
|
||||
dailyLimitGb: ""
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTargetedAccountCheck(
|
||||
option: Pick<AccountAddOption, "service">,
|
||||
identityId: string
|
||||
): TargetedAccountCheck | null {
|
||||
if (option.service !== "megadebrid-api" && option.service !== "debridlink") {
|
||||
return null;
|
||||
}
|
||||
return { service: option.service, expectedStatusId: identityId };
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
.settings-view {
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--ui-border);
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.settings-sidebar-heading {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-sidebar-list {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-sidebar-item {
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-sidebar-item:hover {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-sidebar-item.is-active {
|
||||
border-color: var(--ui-border);
|
||||
background: var(--ui-active);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
background: var(--ui-canvas);
|
||||
}
|
||||
|
||||
.md-runtime-view-content > .settings-content {
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-static {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-content :where(input, textarea, [contenteditable="true"], .settings-copyable) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.settings-content-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-content-header > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-content-header h1,
|
||||
.settings-form-heading h2,
|
||||
.settings-account-heading h2,
|
||||
.settings-form-group h3,
|
||||
.settings-rule-section h3 {
|
||||
margin: 0;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-content-header h1 {
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.settings-save-state {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-save-state.is-dirty,
|
||||
.settings-save-state.is-saving {
|
||||
color: var(--ui-warning);
|
||||
}
|
||||
|
||||
.settings-save-state.is-error {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.settings-content-body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-content-body.is-accounts {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-form-column {
|
||||
width: 500px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.settings-form-heading {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-form-heading h2,
|
||||
.settings-account-heading h2 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.settings-form-heading p,
|
||||
.settings-account-heading p,
|
||||
.settings-form-group > header p,
|
||||
.settings-rule-section > p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.settings-form-group {
|
||||
padding: 22px 0;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.settings-form-group:first-of-type {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-form-group h3,
|
||||
.settings-rule-section h3 {
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.settings-form-fields {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.settings-field > label,
|
||||
.settings-field > legend,
|
||||
.settings-account-dialog-field > span,
|
||||
.settings-switch-label {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-control {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
outline: 0;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text);
|
||||
color-scheme: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-control:focus-visible,
|
||||
.settings-button:focus-visible,
|
||||
.settings-switch:focus-visible,
|
||||
.settings-theme-option:focus-visible,
|
||||
.settings-sidebar-item:focus-visible,
|
||||
.settings-account-tabs button:focus-visible,
|
||||
.settings-account-row:focus-visible,
|
||||
.settings-account-action-button:focus-visible,
|
||||
.settings-account-picker-row:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-control:disabled,
|
||||
.settings-button:disabled,
|
||||
.settings-switch:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-textarea {
|
||||
height: auto;
|
||||
min-height: 108px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.45;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.settings-control-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-field-help {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-switch-field,
|
||||
.settings-action-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-switch-field > div,
|
||||
.settings-action-field > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.settings-switch {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
flex: 0 0 40px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 10px;
|
||||
background: var(--ui-input);
|
||||
}
|
||||
|
||||
.settings-switch > span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--ui-text-muted);
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.settings-switch.is-on {
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-accent);
|
||||
}
|
||||
|
||||
.settings-switch.is-on > span {
|
||||
background: #FFFFFF;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.settings-theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-theme-option {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-theme-option.is-active {
|
||||
border-color: var(--ui-accent);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-theme-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 42px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.settings-theme-preview::after {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 8px;
|
||||
width: 58%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #9AA1AD;
|
||||
box-shadow: 0 9px 0 #737B88, 0 18px 0 #737B88;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.settings-theme-preview.is-light {
|
||||
background: #F3F4F6;
|
||||
}
|
||||
|
||||
.settings-theme-preview.is-dark {
|
||||
background: #1C1D20;
|
||||
}
|
||||
|
||||
.settings-theme-preview.is-system {
|
||||
background: #F3F4F6;
|
||||
}
|
||||
|
||||
.settings-theme-preview.is-system::before {
|
||||
position: absolute;
|
||||
inset: 0 0 0 50%;
|
||||
background: #1C1D20;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
height: 36px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-button-secondary {
|
||||
background: var(--ui-modal-secondary);
|
||||
}
|
||||
|
||||
.settings-button-primary {
|
||||
border-color: var(--ui-primary);
|
||||
background: var(--ui-primary);
|
||||
color: #181A1F;
|
||||
}
|
||||
|
||||
.settings-button-danger {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
background: transparent;
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.settings-button:hover:not(:disabled),
|
||||
.settings-account-tabs button:hover,
|
||||
.settings-account-action-button:hover:not(:disabled) {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-button-primary:hover:not(:disabled) {
|
||||
background: var(--ui-primary-hover);
|
||||
color: #181A1F;
|
||||
}
|
||||
|
||||
.settings-account-workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-account-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-account-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.settings-account-tabs button {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ui-text-muted);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-account-tabs button[aria-selected="true"] {
|
||||
border-bottom-color: var(--ui-accent);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-account-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-account-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-account-table {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.settings-account-table-header {
|
||||
height: 41px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-account-table-grid,
|
||||
.settings-account-row {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(190px, 1.15fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px;
|
||||
min-width: 1110px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-account-table-grid {
|
||||
height: 41px;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-account-table-grid > span,
|
||||
.settings-account-row > span {
|
||||
min-width: 0;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.settings-account-table-body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-account-row {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-account-row::before {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--ui-accent);
|
||||
content: "";
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-account-row:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.settings-account-row.is-selected {
|
||||
background: var(--ui-active);
|
||||
}
|
||||
|
||||
.settings-account-row.is-selected::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-account-row.is-disabled {
|
||||
color: var(--ui-text-muted);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.settings-account-column-enable,
|
||||
.settings-account-column-actions {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.settings-account-hoster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.settings-account-hoster img {
|
||||
flex: 0 0 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.settings-account-hoster > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-account-hoster strong,
|
||||
.settings-account-hoster small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-hoster small {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.settings-account-status-badge {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-status-badge::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ui-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-ok::before {
|
||||
background: var(--ui-primary);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-free::before,
|
||||
.settings-account-status-badge.is-unknown::before {
|
||||
background: var(--ui-warning);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-invalid::before {
|
||||
background: var(--ui-danger);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-disabled::before {
|
||||
background: var(--ui-text-muted);
|
||||
}
|
||||
|
||||
.settings-account-traffic,
|
||||
.settings-account-expires,
|
||||
.settings-account-credential {
|
||||
color: var(--ui-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-username {
|
||||
overflow: hidden;
|
||||
color: var(--ui-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-action-button {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.settings-account-local-actions {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-account-local-actions > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-account-table-error .ui-data-table-empty-title,
|
||||
.settings-account-dialog-error {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.settings-account-rules {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 22px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-rule-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.settings-provider-order,
|
||||
.settings-routing-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.settings-provider-order li,
|
||||
.settings-routing-list li,
|
||||
.settings-rotation-event {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
.settings-provider-order-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-provider-order-actions button {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
background: var(--ui-modal-secondary);
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
.settings-rule-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-rule-empty {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-rotation-event {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-rotation-event span {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-account-dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-account-picker-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 128px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-account-picker {
|
||||
display: grid;
|
||||
max-height: 430px;
|
||||
min-height: 0;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-account-picker-entry {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-account-picker-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 150px;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-account-picker-row.is-selected {
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-active);
|
||||
}
|
||||
|
||||
.settings-account-picker-row > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-account-picker-row small {
|
||||
overflow: hidden;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-picker-empty {
|
||||
padding: 24px;
|
||||
color: var(--ui-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-account-dialog-fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 2px 10px 14px;
|
||||
}
|
||||
|
||||
.settings-account-dialog-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.settings-account-dialog-textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.settings-account-dialog-error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-account-dialog-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.settings-account-dialog-action-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.settings-account-edit-identity {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.settings-account-edit-identity span {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-account-edit-identity strong {
|
||||
color: var(--ui-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-account-edit-enabled {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.settings-view {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-account-table-grid,
|
||||
.settings-account-row {
|
||||
grid-template-columns: 40px 160px 140px 180px 180px 120px 140px 42px;
|
||||
min-width: 1002px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.settings-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.settings-theme-options,
|
||||
.settings-account-picker-controls,
|
||||
.settings-account-picker-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type {
|
||||
StatisticsMetric,
|
||||
StatisticsProviderScope,
|
||||
StatisticsRange,
|
||||
StatisticsViewModel
|
||||
} from "./statistics-model";
|
||||
import "./statistics.css";
|
||||
|
||||
export interface StatisticsViewActions {
|
||||
onRangeChange: (range: StatisticsRange) => void;
|
||||
onResetSession: () => void;
|
||||
onResetAll: () => void;
|
||||
onResetErrors: () => void;
|
||||
}
|
||||
|
||||
export interface StatisticsViewProps {
|
||||
model: StatisticsViewModel;
|
||||
actions: StatisticsViewActions;
|
||||
chart: ReactNode;
|
||||
}
|
||||
|
||||
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
|
||||
{ id: "session", label: "Sitzung" },
|
||||
{ id: "today", label: "Heute" },
|
||||
{ id: "week", label: "Sieben Tage" },
|
||||
{ id: "month", label: "30 Tage" },
|
||||
{ id: "all", label: "Gesamt" }
|
||||
];
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
|
||||
if (safe < 1024) {
|
||||
return `${Math.round(safe)} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = safe / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatMetric(metric: StatisticsMetric, kind: "bytes" | "count" | "percent" | "speed"): string {
|
||||
if (!metric.available || metric.value === null) {
|
||||
return "–";
|
||||
}
|
||||
if (kind === "bytes") {
|
||||
return formatBytes(metric.value);
|
||||
}
|
||||
if (kind === "speed") {
|
||||
return `${formatBytes(metric.value)}/s`;
|
||||
}
|
||||
if (kind === "percent") {
|
||||
return `${numberFormatter.format(metric.value)} %`;
|
||||
}
|
||||
return numberFormatter.format(metric.value);
|
||||
}
|
||||
|
||||
function providerScopeLabel(scope: StatisticsProviderScope | null): string {
|
||||
if (scope === "current-queue") {
|
||||
return "Aktuelle Queue";
|
||||
}
|
||||
if (scope === "today") {
|
||||
return "Heute";
|
||||
}
|
||||
if (scope === "all") {
|
||||
return "Gesamt";
|
||||
}
|
||||
return "Nicht verfügbar";
|
||||
}
|
||||
|
||||
function emptyProviderMessage(model: StatisticsViewModel): string {
|
||||
if (model.coverage === "unavailable") {
|
||||
return model.message;
|
||||
}
|
||||
if (model.providerScope === "today") {
|
||||
return "Heute wurden noch keine Providerbytes erfasst.";
|
||||
}
|
||||
if (model.providerScope === "all") {
|
||||
return "Noch keine gespeicherten Providerbytes vorhanden.";
|
||||
}
|
||||
return "In der aktuellen Queue sind noch keine Providerwerte vorhanden.";
|
||||
}
|
||||
|
||||
function StatisticsMetricCard({
|
||||
label,
|
||||
metric,
|
||||
kind
|
||||
}: {
|
||||
label: string;
|
||||
metric: StatisticsMetric;
|
||||
kind: "bytes" | "count" | "percent" | "speed";
|
||||
}): ReactElement {
|
||||
return (
|
||||
<article className={`statistics-kpi${metric.tone === "danger" ? " statistics-kpi-danger" : ""}`}>
|
||||
<span className="statistics-kpi-label">{label}</span>
|
||||
<strong className="statistics-kpi-value">{formatMetric(metric, kind)}</strong>
|
||||
<span className="statistics-kpi-source">{metric.sourceLabel}</span>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsSidebar({ model, actions }: Pick<StatisticsViewProps, "model" | "actions">): ReactElement {
|
||||
return (
|
||||
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
|
||||
<strong className="statistics-sidebar-heading">Zeitraum</strong>
|
||||
<div className="statistics-range-list">
|
||||
{rangeItems.map((item) => (
|
||||
<button
|
||||
aria-current={model.range === item.id ? "page" : undefined}
|
||||
className={`statistics-range${model.range === item.id ? " statistics-range-active" : ""}`}
|
||||
key={item.id}
|
||||
onClick={() => actions.onRangeChange(item.id)}
|
||||
type="button"
|
||||
>{item.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="statistics-sidebar-message">{model.message}</p>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "model">): ReactElement | null {
|
||||
const metrics = model.metrics;
|
||||
const rows = [
|
||||
metrics.downloadedBytes.available ? `Daten: ${formatMetric(metrics.downloadedBytes, "bytes")}` : null,
|
||||
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
|
||||
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
|
||||
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
|
||||
model.providerScope ? `Provider: ${model.providers.length}` : null
|
||||
].filter((value): value is string => value !== null);
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="statistics-sidebar-status">
|
||||
{rows.map((row) => <span key={row}>{row}</span>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
||||
return (
|
||||
<section aria-label="Statistik-Dashboard" className="statistics-content">
|
||||
<header className="statistics-heading">
|
||||
<div>
|
||||
<h2>Statistiken</h2>
|
||||
<p>{model.message}</p>
|
||||
</div>
|
||||
<div aria-label="Statistiken zurücksetzen" className="statistics-reset-actions">
|
||||
<button className="statistics-reset" onClick={actions.onResetSession} type="button">Sitzung zurücksetzen</button>
|
||||
<button className="statistics-reset" onClick={actions.onResetAll} type="button">Gesamt zurücksetzen</button>
|
||||
<button
|
||||
className="statistics-reset statistics-reset-danger"
|
||||
disabled={!model.errorResetAvailable}
|
||||
onClick={actions.onResetErrors}
|
||||
type="button"
|
||||
>Fehler zurücksetzen</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="statistics-kpis" data-visual-region="statistics-kpis">
|
||||
<StatisticsMetricCard kind="bytes" label="Datenmenge" metric={model.metrics.downloadedBytes} />
|
||||
<StatisticsMetricCard kind="count" label="Dateien" metric={model.metrics.files} />
|
||||
<StatisticsMetricCard kind="percent" label="Erfolgsquote" metric={model.metrics.successRate} />
|
||||
<StatisticsMetricCard kind="speed" label="Durchschnitt" metric={model.metrics.averageSpeedBps} />
|
||||
<StatisticsMetricCard kind="count" label="Fehler" metric={model.metrics.errors} />
|
||||
</div>
|
||||
|
||||
<div className="statistics-detail-grid">
|
||||
<section className="statistics-chart" data-visual-region="statistics-chart">
|
||||
<div className="statistics-section-heading">
|
||||
<h3>Bandbreitenverlauf</h3>
|
||||
<span>Live aus der aktuellen Renderer-Sitzung</span>
|
||||
</div>
|
||||
<div className="statistics-chart-canvas">{chart}</div>
|
||||
</section>
|
||||
|
||||
<section className="statistics-providers">
|
||||
<div className="statistics-section-heading">
|
||||
<h3>Provider</h3>
|
||||
<span>{providerScopeLabel(model.providerScope)}</span>
|
||||
</div>
|
||||
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
|
||||
<div className="statistics-provider-header" role="row">
|
||||
<span role="columnheader">Provider</span>
|
||||
<span role="columnheader">Daten</span>
|
||||
<span role="columnheader">Ergebnisse</span>
|
||||
</div>
|
||||
<div className="statistics-provider-body" role="rowgroup">
|
||||
{model.providers.length > 0 ? model.providers.map((provider) => (
|
||||
<div className="statistics-provider-row" key={provider.id} role="row">
|
||||
<span className="statistics-provider-name" role="cell">{provider.label}</span>
|
||||
<span role="cell">{formatBytes(provider.bytes)}</span>
|
||||
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
|
||||
{provider.completed === null || provider.failed === null
|
||||
? "–"
|
||||
: `${provider.completed} fertig · ${provider.failed} Fehler`}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="statistics-provider-empty" role="row">
|
||||
<span aria-colspan={3} role="cell">{emptyProviderMessage(model)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsView({ model, actions, chart }: StatisticsViewProps): ReactElement {
|
||||
return (
|
||||
<div className="statistics-composed-view">
|
||||
<StatisticsSidebar actions={actions} model={model} />
|
||||
<StatisticsContent actions={actions} chart={chart} model={model} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { getProviderUsageDayKey } from "../../../shared/provider-daily-limits";
|
||||
import type { DebridProvider, DownloadItem, DownloadSummary, UiSnapshot } from "../../../shared/types";
|
||||
|
||||
export type StatisticsRange = "session" | "today" | "week" | "month" | "all";
|
||||
export type StatisticsCoverage = "partial" | "unavailable";
|
||||
export type StatisticsSessionState = "empty" | "idle" | "active" | "paused";
|
||||
export type StatisticsProviderScope = "current-queue" | "today" | "all";
|
||||
export type StatisticsMetricTone = "danger";
|
||||
|
||||
export interface StatisticsMetric {
|
||||
value: number | null;
|
||||
available: boolean;
|
||||
sourceLabel: string;
|
||||
tone?: StatisticsMetricTone;
|
||||
}
|
||||
|
||||
export interface StatisticsProviderRow {
|
||||
id: DebridProvider;
|
||||
label: string;
|
||||
bytes: number;
|
||||
completed: number | null;
|
||||
failed: number | null;
|
||||
}
|
||||
|
||||
export interface StatisticsMetrics {
|
||||
downloadedBytes: StatisticsMetric;
|
||||
files: StatisticsMetric;
|
||||
successRate: StatisticsMetric;
|
||||
averageSpeedBps: StatisticsMetric;
|
||||
errors: StatisticsMetric;
|
||||
}
|
||||
|
||||
export interface StatisticsViewModel {
|
||||
range: StatisticsRange;
|
||||
coverage: StatisticsCoverage;
|
||||
message: string;
|
||||
sessionState: StatisticsSessionState;
|
||||
metrics: StatisticsMetrics;
|
||||
providerScope: StatisticsProviderScope | null;
|
||||
providers: StatisticsProviderRow[];
|
||||
errorResetAvailable: boolean;
|
||||
}
|
||||
|
||||
const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
megadebrid: "Mega-Debrid",
|
||||
"megadebrid-api": "Mega-Debrid API",
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
linksnappy: "LinkSnappy"
|
||||
};
|
||||
|
||||
const historicalUnavailableMessage = "Für diesen Zeitraum werden noch keine historischen Daten gespeichert.";
|
||||
|
||||
function normalizeNonNegative(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function normalizeCount(value: number): number {
|
||||
return Math.floor(normalizeNonNegative(value));
|
||||
}
|
||||
|
||||
function availableMetric(value: number, sourceLabel: string, tone?: StatisticsMetricTone): StatisticsMetric {
|
||||
return {
|
||||
value: normalizeNonNegative(value),
|
||||
available: true,
|
||||
sourceLabel,
|
||||
...(tone ? { tone } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableMetric(sourceLabel: string): StatisticsMetric {
|
||||
return {
|
||||
value: null,
|
||||
available: false,
|
||||
sourceLabel
|
||||
};
|
||||
}
|
||||
|
||||
function sortProviderRows(rows: StatisticsProviderRow[]): StatisticsProviderRow[] {
|
||||
return rows.sort((left, right) => right.bytes - left.bytes || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function deriveSessionState(snapshot: UiSnapshot): StatisticsSessionState {
|
||||
if (snapshot.session.paused) {
|
||||
return "paused";
|
||||
}
|
||||
if (snapshot.session.running) {
|
||||
return "active";
|
||||
}
|
||||
const hasItems = Object.keys(snapshot.session.items).length > 0;
|
||||
const summary = snapshot.summary;
|
||||
const hasSummary = Boolean(summary && normalizeCount(summary.success) + normalizeCount(summary.failed) + normalizeCount(summary.cancelled) > 0);
|
||||
return hasItems || hasSummary ? "idle" : "empty";
|
||||
}
|
||||
|
||||
function countQueueResults(items: DownloadItem[]): { completed: number; failed: number } {
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
for (const item of items) {
|
||||
if (item.status === "completed") {
|
||||
completed += 1;
|
||||
} else if (item.status === "failed") {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { completed, failed };
|
||||
}
|
||||
|
||||
function countSummaryResults(summary: DownloadSummary): { completed: number; failed: number } {
|
||||
return {
|
||||
completed: normalizeCount(summary.success),
|
||||
failed: normalizeCount(summary.failed)
|
||||
};
|
||||
}
|
||||
|
||||
function successRateMetric(completed: number, failed: number, sourceLabel: string): StatisticsMetric {
|
||||
const terminal = completed + failed;
|
||||
return terminal > 0
|
||||
? availableMetric((completed / terminal) * 100, sourceLabel)
|
||||
: unavailableMetric("Keine abgeschlossenen oder fehlgeschlagenen Ergebnisse");
|
||||
}
|
||||
|
||||
function deriveQueueProviders(items: DownloadItem[]): StatisticsProviderRow[] {
|
||||
const providers = new Map<DebridProvider, StatisticsProviderRow>();
|
||||
for (const item of items) {
|
||||
if (!item.provider) {
|
||||
continue;
|
||||
}
|
||||
const existing = providers.get(item.provider);
|
||||
const row = existing ?? {
|
||||
id: item.provider,
|
||||
label: item.providerLabel?.trim() || providerLabels[item.provider],
|
||||
bytes: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
};
|
||||
if (!existing && item.providerLabel?.trim()) {
|
||||
row.label = item.providerLabel.trim();
|
||||
}
|
||||
row.bytes += normalizeNonNegative(item.downloadedBytes);
|
||||
if (item.status === "completed") {
|
||||
row.completed = (row.completed ?? 0) + 1;
|
||||
} else if (item.status === "failed") {
|
||||
row.failed = (row.failed ?? 0) + 1;
|
||||
}
|
||||
providers.set(item.provider, row);
|
||||
}
|
||||
return sortProviderRows([...providers.values()]);
|
||||
}
|
||||
|
||||
function deriveUsageProviders(usage: Partial<Record<DebridProvider, number>>): StatisticsProviderRow[] {
|
||||
const rows: StatisticsProviderRow[] = [];
|
||||
for (const [id, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) {
|
||||
const bytes = normalizeNonNegative(rawBytes ?? 0);
|
||||
if (bytes <= 0 || !providerLabels[id]) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
id,
|
||||
label: providerLabels[id],
|
||||
bytes,
|
||||
completed: null,
|
||||
failed: null
|
||||
});
|
||||
}
|
||||
return sortProviderRows(rows);
|
||||
}
|
||||
|
||||
function sumProviderBytes(rows: StatisticsProviderRow[]): number {
|
||||
return rows.reduce((total, row) => total + row.bytes, 0);
|
||||
}
|
||||
|
||||
function buildUnavailableMetrics(): StatisticsMetrics {
|
||||
return {
|
||||
downloadedBytes: unavailableMetric(historicalUnavailableMessage),
|
||||
files: unavailableMetric(historicalUnavailableMessage),
|
||||
successRate: unavailableMetric(historicalUnavailableMessage),
|
||||
averageSpeedBps: unavailableMetric(historicalUnavailableMessage),
|
||||
errors: unavailableMetric(historicalUnavailableMessage)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStatisticsViewModel(
|
||||
snapshot: UiSnapshot,
|
||||
range: StatisticsRange,
|
||||
nowMs = Date.now()
|
||||
): StatisticsViewModel {
|
||||
const sessionState = deriveSessionState(snapshot);
|
||||
|
||||
if (range === "week" || range === "month") {
|
||||
return {
|
||||
range,
|
||||
coverage: "unavailable",
|
||||
message: historicalUnavailableMessage,
|
||||
sessionState,
|
||||
metrics: buildUnavailableMetrics(),
|
||||
providerScope: null,
|
||||
providers: [],
|
||||
errorResetAvailable: false
|
||||
};
|
||||
}
|
||||
|
||||
if (range === "today") {
|
||||
const isCurrentDay = snapshot.settings.providerDailyUsageDay === getProviderUsageDayKey(nowMs);
|
||||
const providers = isCurrentDay ? deriveUsageProviders(snapshot.settings.providerDailyUsageBytes) : [];
|
||||
return {
|
||||
range,
|
||||
coverage: "partial",
|
||||
message: "Heutige Daten stammen aus den lokalen Provider-Nutzungszählern des aktuellen Kalendertags.",
|
||||
sessionState,
|
||||
metrics: {
|
||||
downloadedBytes: availableMetric(sumProviderBytes(providers), "Provider-Nutzung heute"),
|
||||
files: unavailableMetric("Dateianzahlen werden nicht tagesweise gespeichert"),
|
||||
successRate: unavailableMetric("Ergebnisse werden nicht tagesweise gespeichert"),
|
||||
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert"),
|
||||
errors: unavailableMetric("Fehler werden nicht tagesweise gespeichert")
|
||||
},
|
||||
providerScope: "today",
|
||||
providers,
|
||||
errorResetAvailable: false
|
||||
};
|
||||
}
|
||||
|
||||
if (range === "all") {
|
||||
const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes);
|
||||
return {
|
||||
range,
|
||||
coverage: "partial",
|
||||
message: "Gesamtwerte stammen aus dauerhaft gespeicherten Zählern. Ergebnisse und Geschwindigkeiten werden nicht historisch gespeichert.",
|
||||
sessionState,
|
||||
metrics: {
|
||||
downloadedBytes: availableMetric(snapshot.stats.totalDownloadedAllTime, "Gesamtzähler"),
|
||||
files: availableMetric(snapshot.stats.totalFilesAllTime, "Gesamtzähler"),
|
||||
successRate: unavailableMetric("Ergebnisse werden nicht dauerhaft gespeichert"),
|
||||
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert"),
|
||||
errors: unavailableMetric("Fehler werden nicht dauerhaft gespeichert")
|
||||
},
|
||||
providerScope: "all",
|
||||
providers,
|
||||
errorResetAvailable: false
|
||||
};
|
||||
}
|
||||
|
||||
const items = Object.values(snapshot.session.items);
|
||||
const queueResults = countQueueResults(items);
|
||||
const runInProgress = snapshot.session.running || snapshot.session.paused;
|
||||
const useSummary = !runInProgress && snapshot.summary !== null;
|
||||
const results = useSummary ? countSummaryResults(snapshot.summary as DownloadSummary) : queueResults;
|
||||
const resultSource = useSummary ? "Letzter beendeter Lauf" : "Aktuelle Queue";
|
||||
const failed = results.failed;
|
||||
const summaryAverage = useSummary && results.completed + results.failed > 0
|
||||
? availableMetric((snapshot.summary as DownloadSummary).averageSpeedBps, "Letzter beendeter Lauf")
|
||||
: unavailableMetric(runInProgress
|
||||
? "Während des laufenden Durchgangs nicht als Gesamtwert verfügbar"
|
||||
: "Kein beendeter Lauf mit Ergebnissen verfügbar");
|
||||
|
||||
return {
|
||||
range,
|
||||
coverage: "partial",
|
||||
message: useSummary
|
||||
? "Sitzungszähler und Ergebnisse des zuletzt beendeten Laufs werden angezeigt."
|
||||
: "Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.",
|
||||
sessionState,
|
||||
metrics: {
|
||||
downloadedBytes: availableMetric(snapshot.stats.totalDownloaded, "Sitzungszähler"),
|
||||
files: availableMetric(snapshot.stats.totalFilesSession, "Sitzungszähler"),
|
||||
successRate: successRateMetric(results.completed, failed, resultSource),
|
||||
averageSpeedBps: summaryAverage,
|
||||
errors: availableMetric(failed, resultSource, failed > 0 ? "danger" : undefined)
|
||||
},
|
||||
providerScope: "current-queue",
|
||||
providers: deriveQueueProviders(items),
|
||||
errorResetAvailable: queueResults.failed > 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
.statistics-sidebar,
|
||||
.statistics-content,
|
||||
.statistics-sidebar-status {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.statistics-sidebar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statistics-sidebar-heading {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statistics-range-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statistics-range {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ui-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.statistics-range:hover {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.statistics-range-active {
|
||||
border-color: var(--ui-border);
|
||||
background: var(--ui-active);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.statistics-range:focus-visible,
|
||||
.statistics-reset:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.statistics-sidebar-message {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.statistics-sidebar-status {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.statistics-content {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.statistics-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.statistics-heading h2,
|
||||
.statistics-section-heading h3 {
|
||||
margin: 0;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.statistics-heading h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.statistics-heading p {
|
||||
max-width: 760px;
|
||||
margin: 4px 0 0;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.statistics-reset-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statistics-reset {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statistics-reset:hover:not(:disabled) {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.statistics-reset-danger {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.statistics-reset:disabled {
|
||||
color: var(--ui-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.statistics-kpis {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statistics-kpi {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 116px;
|
||||
padding: 14px 16px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 8px;
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.statistics-kpi-label {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.statistics-kpi-value {
|
||||
min-width: 0;
|
||||
color: var(--ui-text);
|
||||
font-size: clamp(20px, 1.6vw, 28px);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statistics-kpi-danger .statistics-kpi-value {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.statistics-kpi-source {
|
||||
min-height: 32px;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.statistics-detail-grid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.statistics-chart,
|
||||
.statistics-providers {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 340px;
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 8px;
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
|
||||
.statistics-section-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.statistics-section-heading h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.statistics-section-heading span {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.statistics-chart-canvas {
|
||||
min-width: 0;
|
||||
min-height: 280px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.statistics-chart-canvas .bandwidth-chart-container {
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
}
|
||||
|
||||
.statistics-provider-table {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.statistics-provider-header,
|
||||
.statistics-provider-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(88px, 0.65fr) minmax(130px, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statistics-provider-header {
|
||||
min-height: 41px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: var(--ui-table-header);
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.statistics-provider-body {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.statistics-provider-row {
|
||||
min-height: 48px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.statistics-provider-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.statistics-provider-row:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.statistics-provider-row > span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.statistics-provider-name {
|
||||
color: var(--ui-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statistics-provider-errors {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.statistics-provider-empty {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
padding: 24px;
|
||||
place-items: center;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statistics-composed-view {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.statistics-composed-view {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.statistics-sidebar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statistics-kpis {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.statistics-detail-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.statistics-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.statistics-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.statistics-reset-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.statistics-kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user