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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user