feat(collector): rebuild link preview as package workspace
Inspect pasted links, text files and DLC containers before queue insertion and resolve supported hoster metadata without starting downloads. Group multipart files into expandable packages with size, status, availability, timestamps, filtering and stable selection. Add selected or complete transfer to Downloads while retaining entries on failed or partial handoff. Keep collector toolbar spacing consistent and show the full 1Fichier identity with its provider icon in Downloads.
This commit is contained in:
@@ -1,210 +1,149 @@
|
||||
import type { ChangeEvent, ReactElement } from "react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import type { ChangeEvent, ReactElement } from "react";
|
||||
import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import type { CollectorViewModel } from "./collector-model";
|
||||
import "./collector.css";
|
||||
|
||||
export interface CollectorViewActions {
|
||||
onTabSelect: (tabId: string) => void;
|
||||
onTabAdd: () => void;
|
||||
onTabRemove: (tabId: string) => void;
|
||||
onOpenInput: () => void;
|
||||
onImportDlc: () => void;
|
||||
onImportFile: () => void;
|
||||
onExportQueue: () => void;
|
||||
onSubmit: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelectionChange: (rowId: string) => void;
|
||||
onRemoveSelected: () => void;
|
||||
}
|
||||
|
||||
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
|
||||
|
||||
export interface CollectorViewProps {
|
||||
model: CollectorViewModel;
|
||||
actions: CollectorViewActions;
|
||||
region?: CollectorViewRegion;
|
||||
}
|
||||
|
||||
export interface CollectorInputDialogProps {
|
||||
open: boolean;
|
||||
tabName: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format";
|
||||
import type { CollectorWorkspaceFilter, CollectorWorkspacePackageRow, CollectorWorkspaceViewModel } from "./collector-model";
|
||||
import "./collector.css";
|
||||
|
||||
export interface CollectorViewActions {
|
||||
onFilterChange: (filter: CollectorWorkspaceFilter) => void;
|
||||
onOpenInput: () => void;
|
||||
onImportDlc: () => void;
|
||||
onImportFile: () => void;
|
||||
onSubmitSelected: () => void;
|
||||
onSubmitAll: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onLinkSelectionChange: (linkId: string, selected: boolean) => void;
|
||||
onPackageSelectionChange: (packageId: string, selected: boolean) => void;
|
||||
onPackageCollapseChange: (packageId: string) => void;
|
||||
onRemoveSelected: () => void;
|
||||
}
|
||||
|
||||
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
|
||||
|
||||
export interface CollectorViewProps {
|
||||
model: CollectorWorkspaceViewModel;
|
||||
actions: CollectorViewActions;
|
||||
region?: CollectorViewRegion;
|
||||
}
|
||||
|
||||
export interface CollectorInputDialogProps {
|
||||
open: boolean;
|
||||
tabName?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
function packageStatus(row: CollectorWorkspacePackageRow): string {
|
||||
const ready = row.allLinks.filter((link) => link.status === "ready").length;
|
||||
if (row.offlineCount === row.totalCount) return "Offline";
|
||||
if (ready === row.totalCount) return "Bereit";
|
||||
if (ready > 0 || row.onlineCount > 0) return `${ready}/${row.totalCount} geprüft`;
|
||||
return "Ungeprüft";
|
||||
}
|
||||
|
||||
function packageAvailability(row: CollectorWorkspacePackageRow): string {
|
||||
if (row.onlineCount === row.totalCount) return `${row.onlineCount}/${row.totalCount} online`;
|
||||
if (row.offlineCount === row.totalCount) return "Offline";
|
||||
if (row.onlineCount > 0) return `${row.onlineCount}/${row.totalCount} online`;
|
||||
return "Ungeprüft";
|
||||
}
|
||||
|
||||
function packageSize(row: CollectorWorkspacePackageRow): string {
|
||||
if (row.totalBytes <= 0) return "Unbekannt";
|
||||
return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`;
|
||||
}
|
||||
|
||||
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>
|
||||
<SlidingSelection activeKey={model.activeTabId} axis="vertical" className="collector-sidebar-list">
|
||||
{model.tabs.map((tab) => (
|
||||
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} data-sliding-selection-active={tab.id === model.activeTabId} data-sliding-selection-item="true" key={tab.id}>
|
||||
<button
|
||||
aria-current={tab.id === model.activeTabId ? "page" : undefined}
|
||||
className="collector-sidebar-select"
|
||||
onClick={() => actions.onTabSelect(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{tab.name}</span>
|
||||
<span className="collector-sidebar-count">{tab.linkCount}</span>
|
||||
</button>
|
||||
{model.tabs.length > 1 ? (
|
||||
<button
|
||||
aria-label={`${tab.name} entfernen`}
|
||||
className="collector-sidebar-remove"
|
||||
onClick={() => actions.onTabRemove(tab.id)}
|
||||
type="button"
|
||||
>×</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div aria-label="Linksammler-Filter" className="collector-sidebar" data-visual-region="collector-sidebar">
|
||||
<div className="collector-sidebar-heading"><strong>Status</strong><span>{model.totalCount}</span></div>
|
||||
<SlidingSelection activeKey={model.filter} axis="vertical" className="collector-sidebar-list">
|
||||
{model.filters.map((filter) => (
|
||||
<button aria-current={filter.id === model.filter ? "page" : undefined} className={`collector-sidebar-filter${filter.id === model.filter ? " is-active" : ""}`} data-sliding-selection-active={filter.id === model.filter} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button">
|
||||
<span>{filter.label}</span><span>{filter.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</SlidingSelection>
|
||||
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
const activeTab = model.tabs.find((tab) => tab.id === model.activeTabId) ?? model.tabs[0];
|
||||
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 || !activeTab || activeTab.linkCount === 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={`${row.value} aus ${row.tabName}, Zeile ${row.lineNumber} 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>
|
||||
);
|
||||
}
|
||||
<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="Downloads übergeben">
|
||||
<button className="collector-action" disabled={model.busy || model.selectedCount === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${model.selectedCount})`}</button>
|
||||
<button className="collector-action" disabled={model.busy || model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
|
||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSearch label="Links durchsuchen" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} />
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectorPackageGroup({ row, model, actions }: { row: CollectorWorkspacePackageRow; model: CollectorWorkspaceViewModel; actions: CollectorViewActions }): ReactElement {
|
||||
const selected = new Set(model.selectedIds);
|
||||
const allSelected = row.selectedCount === row.totalCount;
|
||||
const partiallySelected = row.selectedCount > 0 && !allSelected;
|
||||
const animateItems = model.animationsEnabled && row.allLinks.length <= 64;
|
||||
const renderItems = !row.collapsed || animateItems;
|
||||
return (
|
||||
<div className={`collector-package-group${row.collapsed ? " is-collapsed" : ""}${model.animationsEnabled ? " is-motion-enabled" : ""}`} role="rowgroup">
|
||||
<div className={`collector-package-row${row.selectedCount > 0 ? " is-selected" : ""}`} role="row">
|
||||
<span className="collector-column-select" role="cell"><input aria-checked={partiallySelected ? "mixed" : allSelected} aria-label={`Paket ${row.name} auswählen`} checked={allSelected} onChange={(event) => actions.onPackageSelectionChange(row.id, event.target.checked)} ref={(node) => { if (node) node.indeterminate = partiallySelected; }} type="checkbox" /></span>
|
||||
<span className="collector-name-cell" role="cell"><button aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${row.name} ausklappen` : `${row.name} einklappen`} className="collector-collapse-button" onClick={() => actions.onPackageCollapseChange(row.id)} type="button">{row.collapsed ? "+" : "−"}</button><strong title={row.name}>{row.name}</strong><small>{row.totalCount} Dateien</small></span>
|
||||
<span className="collector-size-cell" role="cell">{packageSize(row)}</span>
|
||||
<span className="collector-hoster-cell" role="cell">{row.hosters.map(formatHosterLabel).map((hoster) => <span key={hoster.title} title={hoster.title}>{hoster.compact}</span>)}</span>
|
||||
<span className="collector-status-cell" role="cell">{packageStatus(row)}</span>
|
||||
<span className={`collector-availability-cell is-${row.offlineCount === row.totalCount ? "offline" : row.onlineCount === row.totalCount ? "online" : "unknown"}`} role="cell">{packageAvailability(row)}</span>
|
||||
<span className="collector-added-cell" role="cell">{formatDateTime(row.addedAt)}</span>
|
||||
</div>
|
||||
{renderItems ? <div className={`collector-package-items-frame${row.collapsed ? " is-collapsed" : ""}${animateItems ? " is-animated" : ""}`}><div className="collector-package-items">{row.links.map((link) => {
|
||||
const hoster = formatHosterLabel(link.hoster);
|
||||
return (
|
||||
<div className={`collector-file-row${selected.has(link.id) ? " is-selected" : ""}`} key={link.id} role="row">
|
||||
<span className="collector-column-select" role="cell"><input aria-label={`${link.fileName} auswählen`} checked={selected.has(link.id)} onChange={(event) => actions.onLinkSelectionChange(link.id, event.target.checked)} type="checkbox" /></span>
|
||||
<span className="collector-name-cell is-file" role="cell" title={link.url}><span className={`collector-link-state is-${link.availability}`} />{link.fileName}</span>
|
||||
<span className="collector-size-cell" role="cell">{link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)}</span>
|
||||
<span className="collector-hoster-cell" role="cell"><span title={hoster.title}>{hoster.compact}</span></span>
|
||||
<span className="collector-status-cell" role="cell">{link.status === "ready" ? "Bereit" : link.status === "offline" ? "Offline" : "Ungeprüft"}</span>
|
||||
<span className={`collector-availability-cell is-${link.availability}`} role="cell">{link.availability === "online" ? "Online" : link.availability === "offline" ? "Offline" : "Ungeprüft"}</span>
|
||||
<span className="collector-added-cell" role="cell">{formatDateTime(link.addedAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})}</div></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
||||
return (
|
||||
<section className="collector-content" aria-label="Gesammelte Downloadpakete"><DataTable className="collector-table" label="Gesammelte Downloadpakete">
|
||||
<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">Name</span><span role="columnheader">Größe</span><span role="columnheader">Hoster</span><span role="columnheader">Status</span><span role="columnheader">Verfügbarkeit</span><span role="columnheader">Hinzugefügt</span></div></DataTableHeader>
|
||||
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
||||
{model.busy ? <DataTableEmpty title="Links werden analysiert" description="Dateinamen, Größen und Verfügbarkeit werden geprüft." /> : model.error ? <DataTableEmpty className="collector-table-error" title={model.error} description="Bereits gesammelte Pakete bleiben unverändert." /> : model.empty ? <DataTableEmpty data-visual-region="collector-empty-state" description={model.query || model.filter !== "all" ? "Passe Suche oder Statusfilter an." : "Füge Links hinzu, um Pakete vor dem Download zu prüfen."} title={model.query || model.filter !== "all" ? "Keine passenden Links" : "Noch keine Links"} /> : model.packages.map((row) => <CollectorPackageGroup actions={actions} key={row.id} model={model} row={row} />)}
|
||||
</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, 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">Analysieren</button></>} description="Links werden geprüft und automatisch zu Downloadpaketen gruppiert." 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 pro Zeile" rows={12} value={value} /></label></Dialog>;
|
||||
}
|
||||
|
||||
@@ -1,88 +1,168 @@
|
||||
export interface CollectorSourceTab {
|
||||
import type { CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||
|
||||
export type CollectorWorkspaceFilter = "all" | "online" | "unknown" | "offline";
|
||||
|
||||
export interface CollectorWorkspaceFilterEntry {
|
||||
id: CollectorWorkspaceFilter;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CollectorWorkspacePackageRow {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
links: CollectorLink[];
|
||||
allLinks: CollectorLink[];
|
||||
totalBytes: number;
|
||||
unknownSizeCount: number;
|
||||
onlineCount: number;
|
||||
offlineCount: number;
|
||||
unknownCount: number;
|
||||
totalCount: number;
|
||||
selectedCount: number;
|
||||
collapsed: boolean;
|
||||
addedAt: number;
|
||||
hosters: 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;
|
||||
export interface CollectorWorkspaceViewModel {
|
||||
packages: CollectorWorkspacePackageRow[];
|
||||
filters: CollectorWorkspaceFilterEntry[];
|
||||
filter: CollectorWorkspaceFilter;
|
||||
query: string;
|
||||
selectedIds: string[];
|
||||
empty: boolean;
|
||||
busy: boolean;
|
||||
error: string;
|
||||
empty: boolean;
|
||||
totalCount: number;
|
||||
selectedCount: number;
|
||||
selectedIds: string[];
|
||||
animationsEnabled: boolean;
|
||||
}
|
||||
|
||||
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 mergeCollectorPackages(
|
||||
current: CollectorPackage[],
|
||||
incoming: CollectorPackage[]
|
||||
): { packages: CollectorPackage[]; addedLinks: number; duplicateLinks: number } {
|
||||
const packages = current.map((pkg) => ({ ...pkg, links: [...pkg.links] }));
|
||||
const packageByName = new Map(packages.map((pkg) => [pkg.name.toLocaleLowerCase("de"), pkg]));
|
||||
const seenUrls = new Set(packages.flatMap((pkg) => pkg.links.map((link) => link.url)));
|
||||
let addedLinks = 0;
|
||||
let duplicateLinks = 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 [];
|
||||
for (const incomingPackage of incoming) {
|
||||
let target = packageByName.get(incomingPackage.name.toLocaleLowerCase("de"));
|
||||
for (const link of incomingPackage.links) {
|
||||
if (seenUrls.has(link.url)) {
|
||||
duplicateLinks += 1;
|
||||
continue;
|
||||
}
|
||||
if (!target) {
|
||||
target = { ...incomingPackage, links: [], addedAt: incomingPackage.addedAt };
|
||||
packages.push(target);
|
||||
packageByName.set(incomingPackage.name.toLocaleLowerCase("de"), target);
|
||||
}
|
||||
target.links.push(link);
|
||||
target.addedAt = Math.min(target.addedAt, link.addedAt);
|
||||
seenUrls.add(link.url);
|
||||
addedLinks += 1;
|
||||
}
|
||||
}
|
||||
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
|
||||
}));
|
||||
|
||||
return { packages, addedLinks, duplicateLinks };
|
||||
}
|
||||
|
||||
export function buildCollectorViewModel(
|
||||
tabs: CollectorSourceTab[],
|
||||
activeTabId: string,
|
||||
export function selectCollectorPackageLinks(
|
||||
current: Set<string>,
|
||||
pkg: CollectorPackage,
|
||||
selected: boolean
|
||||
): Set<string> {
|
||||
const next = new Set(current);
|
||||
for (const link of pkg.links) {
|
||||
if (selected) next.add(link.id);
|
||||
else next.delete(link.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildCollectorTransferPackages(packages: CollectorPackage[], selectedIds: Set<string>): CollectorPackage[] {
|
||||
return packages.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => selectedIds.has(link.id));
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function removeCollectorLinks(packages: CollectorPackage[], removedIds: Set<string>): CollectorPackage[] {
|
||||
return packages.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => !removedIds.has(link.id));
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function filterCollectorLink(link: CollectorLink, filter: CollectorWorkspaceFilter): boolean {
|
||||
return filter === "all" || link.availability === filter;
|
||||
}
|
||||
|
||||
function collectorLinkMatchesQuery(link: CollectorLink, query: string): boolean {
|
||||
if (!query) return true;
|
||||
return `${link.fileName}\n${link.url}\n${link.hoster}\n${link.status}\n${link.availability}`.toLocaleLowerCase("de").includes(query);
|
||||
}
|
||||
|
||||
export function buildCollectorWorkspaceViewModel(
|
||||
packages: CollectorPackage[],
|
||||
filter: CollectorWorkspaceFilter,
|
||||
query: string,
|
||||
busy: boolean,
|
||||
selectedIds: string[],
|
||||
error = ""
|
||||
): CollectorViewModel {
|
||||
const rows = buildCollectorRows(tabs, activeTabId, query);
|
||||
collapsedPackageIds: string[],
|
||||
error = "",
|
||||
animationsEnabled = true
|
||||
): CollectorWorkspaceViewModel {
|
||||
const selected = new Set(selectedIds);
|
||||
const collapsed = new Set(collapsedPackageIds);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("de");
|
||||
const allLinks = packages.flatMap((pkg) => pkg.links);
|
||||
const countAvailability = (availability: CollectorLink["availability"]): number => allLinks.filter((link) => link.availability === availability).length;
|
||||
const rows: CollectorWorkspacePackageRow[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
const packageMatches = !normalizedQuery || pkg.name.toLocaleLowerCase("de").includes(normalizedQuery);
|
||||
const visibleLinks = pkg.links.filter((link) => filterCollectorLink(link, filter)
|
||||
&& (packageMatches || collectorLinkMatchesQuery(link, normalizedQuery)));
|
||||
if (visibleLinks.length === 0) continue;
|
||||
rows.push({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
links: visibleLinks,
|
||||
allLinks: pkg.links,
|
||||
totalBytes: pkg.links.reduce((sum, link) => sum + (link.fileSizeBytes || 0), 0),
|
||||
unknownSizeCount: pkg.links.filter((link) => link.fileSizeBytes === null).length,
|
||||
onlineCount: pkg.links.filter((link) => link.availability === "online").length,
|
||||
offlineCount: pkg.links.filter((link) => link.availability === "offline").length,
|
||||
unknownCount: pkg.links.filter((link) => link.availability === "unknown").length,
|
||||
totalCount: pkg.links.length,
|
||||
selectedCount: pkg.links.filter((link) => selected.has(link.id)).length,
|
||||
collapsed: collapsed.has(pkg.id),
|
||||
addedAt: pkg.addedAt,
|
||||
hosters: [...new Set(pkg.links.map((link) => link.hoster).filter(Boolean))]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
name: tab.name,
|
||||
linkCount: nonEmptyLines(tab).length
|
||||
})),
|
||||
activeTabId,
|
||||
rows,
|
||||
busy,
|
||||
packages: rows,
|
||||
filters: [
|
||||
{ id: "all", label: "Alle Links", count: allLinks.length },
|
||||
{ id: "online", label: "Online", count: countAvailability("online") },
|
||||
{ id: "unknown", label: "Ungeprüft", count: countAvailability("unknown") },
|
||||
{ id: "offline", label: "Offline", count: countAvailability("offline") }
|
||||
],
|
||||
filter,
|
||||
query,
|
||||
selectedIds,
|
||||
busy,
|
||||
error,
|
||||
empty: rows.length === 0,
|
||||
error
|
||||
totalCount: allLinks.length,
|
||||
selectedCount: allLinks.filter((link) => selected.has(link.id)).length,
|
||||
selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id),
|
||||
animationsEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,318 +1,62 @@
|
||||
.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: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.collector-sidebar-select,
|
||||
.collector-sidebar-remove,
|
||||
.collector-sidebar-add {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.collector-sidebar-select {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collector-sidebar-select span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-sidebar-remove {
|
||||
border-left: 1px solid var(--ui-border);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.collector-sidebar-add {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collector-sidebar-add:hover,
|
||||
.collector-sidebar-remove:hover {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.collector-toolbar {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-action {
|
||||
background: var(--ui-input);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-action:hover:not(:disabled) {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.collector-action-primary {
|
||||
background: var(--ui-primary);
|
||||
border-color: var(--ui-primary);
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-action-primary:hover:not(:disabled) {
|
||||
background: var(--ui-primary-hover);
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.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-secondary);
|
||||
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: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.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-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collector-toolbar .ui-toolbar-search {
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
||||
min-width: 610px;
|
||||
}
|
||||
}
|
||||
.collector-view { display: grid; grid-template-columns: 230px 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 { 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-filter { align-items: center; background: transparent; border: 1px solid transparent; border-radius: 6px; color: var(--ui-text-secondary); display: flex; font: inherit; justify-content: space-between; min-height: 36px; padding: 0 10px; text-align: left; width: 100%; }
|
||||
.collector-sidebar-filter:hover { background: var(--ui-hover); color: var(--ui-text); }
|
||||
.collector-sidebar-filter.is-active { color: var(--ui-text); }
|
||||
.collector-sidebar-filter span:last-child { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; }
|
||||
.collector-toolbar { --collector-toolbar-action-gap: 4px; gap: var(--collector-toolbar-action-gap); min-width: 0; width: 100%; }
|
||||
.collector-toolbar .ui-toolbar-group { gap: var(--collector-toolbar-action-gap); }
|
||||
.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: var(--ui-primary-text); }
|
||||
.collector-action-primary:hover:not(:disabled) { background: var(--ui-primary-hover); color: var(--ui-primary-text); }
|
||||
.collector-action-danger:not(:disabled) { border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border)); color: var(--ui-danger-text); }
|
||||
.collector-action:disabled { cursor: default; opacity: .45; }
|
||||
.collector-content { display: flex; height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.collector-table { height: 100%; }
|
||||
.collector-table-header { height: 41px; }
|
||||
.collector-table-header-row, .collector-package-row, .collector-file-row { align-items: center; display: grid; grid-template-columns: 48px minmax(260px, 2.4fr) minmax(110px, .8fr) minmax(90px, .65fr) minmax(115px, .9fr) minmax(130px, 1fr) minmax(150px, 1fr); min-width: 960px; }
|
||||
.collector-table-header-row { color: var(--ui-text-secondary); font-size: 11px; font-weight: 700; height: 41px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.collector-table-header-row > span, .collector-package-row > span, .collector-file-row > span { min-width: 0; padding: 0 12px; }
|
||||
.collector-table-body { overflow: auto; }
|
||||
.collector-package-group { contain-intrinsic-size: 46px 654px; content-visibility: auto; }
|
||||
.collector-package-row { background: color-mix(in srgb, var(--ui-active) 34%, var(--ui-surface)); border-bottom: 1px solid var(--ui-border); color: var(--ui-text-secondary); height: 46px; }
|
||||
.collector-file-row { border-bottom: 1px solid var(--ui-border); color: var(--ui-text-secondary); height: 40px; }
|
||||
.collector-package-row:hover, .collector-file-row:hover { background: var(--ui-hover); }
|
||||
.collector-package-row.is-selected, .collector-file-row.is-selected { background: var(--ui-active); }
|
||||
.collector-package-items-frame { display: grid; grid-template-rows: 1fr; min-height: 0; opacity: 1; }
|
||||
.collector-package-items-frame.is-animated { transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1), opacity 300ms ease; }
|
||||
.collector-package-items-frame.is-collapsed { grid-template-rows: 0fr; opacity: 0; pointer-events: none; }
|
||||
.collector-package-items { min-height: 0; overflow: hidden; }
|
||||
.collector-column-select { display: grid; place-items: center; }
|
||||
.collector-name-cell { align-items: center; display: flex; gap: 8px; min-width: 0; }
|
||||
.collector-name-cell strong, .collector-name-cell.is-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.collector-name-cell small { color: var(--ui-text-muted); white-space: nowrap; }
|
||||
.collector-name-cell.is-file { color: var(--ui-text); padding-left: 36px; }
|
||||
.collector-collapse-button { align-items: center; background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 5px; color: var(--ui-text-secondary); display: inline-flex; flex: 0 0 28px; height: 28px; justify-content: center; }
|
||||
.collector-collapse-button:hover { background: var(--ui-hover); color: var(--ui-text); }
|
||||
.collector-link-state { border-radius: 50%; flex: 0 0 8px; height: 8px; }
|
||||
.collector-link-state.is-online { background: var(--ui-success); }
|
||||
.collector-link-state.is-offline { background: var(--ui-danger); }
|
||||
.collector-link-state.is-unknown { background: var(--ui-text-muted); }
|
||||
.collector-size-cell, .collector-added-cell { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.collector-hoster-cell { align-items: center; display: flex; gap: 5px; }
|
||||
.collector-hoster-cell span { color: var(--ui-text); font-weight: 600; }
|
||||
.collector-status-cell, .collector-added-cell { color: var(--ui-text-muted); }
|
||||
.collector-availability-cell { font-weight: 600; white-space: nowrap; }
|
||||
.collector-availability-cell.is-online { color: var(--ui-success); }
|
||||
.collector-availability-cell.is-offline { color: var(--ui-danger); }
|
||||
.collector-availability-cell.is-unknown { color: var(--ui-text-muted); }
|
||||
.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: var(--ui-primary-text); }
|
||||
.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-sidebar-filter { padding: 0 7px; } .collector-sidebar-filter span:first-child { overflow: hidden; text-overflow: ellipsis; } .collector-action { padding: 0 9px; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 44px minmax(220px, 2.2fr) minmax(98px, .8fr) minmax(78px, .65fr) minmax(100px, .9fr) minmax(112px, 1fr) minmax(132px, 1fr); min-width: 820px; } }
|
||||
@media (max-width: 1120px) { .collector-toolbar { flex-wrap: wrap; } .collector-toolbar .ui-toolbar-search { flex: 1 0 100%; width: 100%; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, .8fr) minmax(72px, .65fr) minmax(94px, .9fr) minmax(106px, 1fr) minmax(124px, 1fr); min-width: 760px; } }
|
||||
|
||||
Reference in New Issue
Block a user