Restore package-based link collector without blocking DLC drops
Reintroduce expandable collector packages, background metadata enrichment, filters, selection, and controlled queue transfer. Keep DLC files dropped outside the collector on the direct addContainers path without settings persistence or metadata waits. Protect collector state with stable URL identities, non-degrading metadata merges, and per-URL generations so stale enrichment responses cannot overwrite newer data or resurrect removed links.
This commit is contained in:
@@ -1,210 +1,303 @@
|
||||
import type { ChangeEvent, ReactElement } from "react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import type { ChangeEvent, ReactElement } from "react";
|
||||
import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format";
|
||||
import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
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 { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
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;
|
||||
onToggleAllPackages: () => 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.reduce((count, link) => count + (link.status === "ready" ? 1 : 0), 0);
|
||||
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)}`;
|
||||
}
|
||||
|
||||
function availabilityClass(row: CollectorWorkspacePackageRow): string {
|
||||
if (row.offlineCount === row.totalCount) return "offline";
|
||||
if (row.onlineCount === row.totalCount) return "online";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function linkStatus(status: "ready" | "offline" | "unknown"): string {
|
||||
if (status === "ready") return "Bereit";
|
||||
if (status === "offline") return "Offline";
|
||||
return "Ungeprüft";
|
||||
}
|
||||
|
||||
function linkAvailability(availability: "online" | "offline" | "unknown"): string {
|
||||
if (availability === "online") return "Online";
|
||||
if (availability === "offline") return "Offline";
|
||||
return "Ungeprüft";
|
||||
}
|
||||
|
||||
export function toggleAllCollectorPackageIds(
|
||||
packageIds: readonly string[],
|
||||
collapsedPackageIds: ReadonlySet<string>
|
||||
): Set<string> {
|
||||
return packageIds.some((packageId) => !collapsedPackageIds.has(packageId))
|
||||
? new Set(packageIds)
|
||||
: new Set();
|
||||
}
|
||||
|
||||
function CollectorHosterLabel({ hoster }: { hoster: ReturnType<typeof formatHosterLabel> }): ReactElement {
|
||||
return (
|
||||
<span className="collector-hoster-label" title={hoster.title}>
|
||||
{hoster.iconSrc ? (
|
||||
<>
|
||||
<img
|
||||
alt=""
|
||||
className="collector-hoster-icon"
|
||||
data-hoster={hoster.title.toLowerCase()}
|
||||
onError={(event) => {
|
||||
event.currentTarget.hidden = true;
|
||||
event.currentTarget.nextElementSibling?.removeAttribute("hidden");
|
||||
}}
|
||||
src={hoster.iconSrc}
|
||||
/>
|
||||
<span hidden>{hoster.compact}</span>
|
||||
</>
|
||||
) : hoster.compact}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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" onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||
<button className="collector-action" onClick={actions.onImportDlc} type="button">DLC importieren</button>
|
||||
<button className="collector-action" onClick={actions.onImportFile} type="button">Datei importieren</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup label="Downloads übergeben">
|
||||
<button className="collector-action" disabled={model.selectedCount === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${model.selectedCount})`}</button>
|
||||
<button className="collector-action" disabled={model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
|
||||
<button className="collector-action collector-action-danger" disabled={model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup className="collector-toolbar-tail" label="Suche und Paketdarstellung">
|
||||
<ToolbarSearch label="Links durchsuchen" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} />
|
||||
<button className="collector-action" disabled={model.totalCount === 0} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
</ToolbarGroup>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectorPackageGroup({ row, model, actions, selected }: {
|
||||
row: CollectorWorkspacePackageRow;
|
||||
model: CollectorWorkspaceViewModel;
|
||||
actions: CollectorViewActions;
|
||||
selected: ReadonlySet<string>;
|
||||
}): ReactElement {
|
||||
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) => <CollectorHosterLabel hoster={hoster} key={hoster.title} />)}
|
||||
</span>
|
||||
<span className="collector-status-cell" role="cell">{packageStatus(row)}</span>
|
||||
<span className={`collector-availability-cell is-${availabilityClass(row)}`} 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"><CollectorHosterLabel hoster={hoster} /></span>
|
||||
<span className="collector-status-cell" role="cell">{linkStatus(link.status)}</span>
|
||||
<span className={`collector-availability-cell is-${link.availability}`} role="cell">{linkAvailability(link.availability)}</span>
|
||||
<span className="collector-added-cell" role="cell">{formatDateTime(link.addedAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
||||
const selected = new Set(model.selectedIds);
|
||||
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.analyzing ? <div aria-live="polite" className="collector-background-state" role="status"><span />Analyse läuft im Hintergrund</div> : null}
|
||||
{model.error ? <div aria-live="polite" className="collector-background-error" role="status">{model.error}</div> : null}
|
||||
{model.empty ? (
|
||||
<DataTableEmpty
|
||||
data-visual-region="collector-empty-state"
|
||||
description={model.query || model.filter !== "all" ? "Passe Suche oder Statusfilter an." : model.analyzing ? "Die ersten Links erscheinen sofort nach dem Import." : "Füge Links hinzu, um Pakete vor dem Download zu prüfen."}
|
||||
title={model.query || model.filter !== "all" ? "Keine passenden Links" : model.analyzing ? "Links werden vorbereitet" : "Noch keine Links"}
|
||||
/>
|
||||
) : model.packages.map((row) => <CollectorPackageGroup actions={actions} key={row.id} model={model} row={row} selected={selected} />)}
|
||||
</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">Hinzufügen</button>
|
||||
</>
|
||||
)}
|
||||
description="Links erscheinen sofort und werden anschließend im Hintergrund geprüft."
|
||||
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,295 @@
|
||||
export interface CollectorSourceTab {
|
||||
import type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||
|
||||
export type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||
export type CollectorWorkspaceFilter = "all" | CollectorAvailability;
|
||||
|
||||
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;
|
||||
analyzing: 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 interface CollectorMergeResult {
|
||||
packages: CollectorPackage[];
|
||||
addedLinks: number;
|
||||
duplicateLinks: number;
|
||||
enrichedLinks: number;
|
||||
}
|
||||
|
||||
export function buildCollectorRows(
|
||||
tabs: CollectorSourceTab[],
|
||||
activeTabId: string = tabs[0]?.id ?? "",
|
||||
query = ""
|
||||
): CollectorRow[] {
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
|
||||
if (!activeTab) {
|
||||
return [];
|
||||
}
|
||||
const lines = nonEmptyLines(activeTab);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("de");
|
||||
return lines
|
||||
.filter((line) => !normalizedQuery || line.value.toLocaleLowerCase("de").includes(normalizedQuery))
|
||||
.map((line) => ({
|
||||
id: `${activeTab.id}:${line.originalLineIndex}`,
|
||||
tabId: activeTab.id,
|
||||
tabName: activeTab.name,
|
||||
originalLineIndex: line.originalLineIndex,
|
||||
lineNumber: line.originalLineIndex + 1,
|
||||
value: line.value,
|
||||
linkCount: lines.length
|
||||
}));
|
||||
function collectorUrlKey(url: string): string {
|
||||
return url.trim();
|
||||
}
|
||||
|
||||
export function buildCollectorViewModel(
|
||||
tabs: CollectorSourceTab[],
|
||||
activeTabId: string,
|
||||
query: string,
|
||||
busy: boolean,
|
||||
selectedIds: string[],
|
||||
error = ""
|
||||
): CollectorViewModel {
|
||||
const rows = buildCollectorRows(tabs, activeTabId, query);
|
||||
function sameCollectorMetadata(left: CollectorLink, right: CollectorLink): boolean {
|
||||
return left.fileName === right.fileName
|
||||
&& left.fileSizeBytes === right.fileSizeBytes
|
||||
&& left.hoster === right.hoster
|
||||
&& left.availability === right.availability
|
||||
&& left.status === right.status;
|
||||
}
|
||||
|
||||
function incomingCollectorMetadataDegrades(existing: CollectorLink, incoming: CollectorLink): boolean {
|
||||
return (existing.status !== "unknown" && incoming.status === "unknown")
|
||||
|| (existing.availability !== "unknown" && incoming.availability === "unknown")
|
||||
|| (existing.fileSizeBytes !== null && incoming.fileSizeBytes === null);
|
||||
}
|
||||
|
||||
function mergeCollectorLinkMetadata(existing: CollectorLink, incoming: CollectorLink): CollectorLink {
|
||||
const preserveKnownName = existing.status === "ready" && incoming.status === "unknown";
|
||||
return {
|
||||
tabs: tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
name: tab.name,
|
||||
linkCount: nonEmptyLines(tab).length
|
||||
})),
|
||||
activeTabId,
|
||||
rows,
|
||||
busy,
|
||||
...existing,
|
||||
...incoming,
|
||||
id: existing.id,
|
||||
url: existing.url,
|
||||
fileName: preserveKnownName ? existing.fileName : (incoming.fileName || existing.fileName),
|
||||
fileSizeBytes: incoming.fileSizeBytes ?? existing.fileSizeBytes,
|
||||
hoster: incoming.hoster || existing.hoster,
|
||||
availability: incoming.availability === "unknown" ? existing.availability : incoming.availability,
|
||||
status: incoming.status === "unknown" ? existing.status : incoming.status,
|
||||
addedAt: Math.min(existing.addedAt, incoming.addedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function packageNameKey(name: string): string {
|
||||
return name.trim().toLocaleLowerCase("de");
|
||||
}
|
||||
|
||||
export function mergeCollectorPackages(current: CollectorPackage[], incoming: CollectorPackage[]): CollectorMergeResult {
|
||||
const packages = current.map((pkg) => ({ ...pkg, links: pkg.links.map((link) => ({ ...link })) }));
|
||||
const packageByName = new Map(packages.map((pkg) => [packageNameKey(pkg.name), pkg]));
|
||||
const existingByUrl = new Map<string, { pkg: CollectorPackage; link: CollectorLink }>();
|
||||
for (const pkg of packages) {
|
||||
for (const link of pkg.links) existingByUrl.set(collectorUrlKey(link.url), { pkg, link });
|
||||
}
|
||||
const replacements = new Map<CollectorLink, CollectorLink>();
|
||||
const movedLinks = new Set<CollectorLink>();
|
||||
const appendedLinks = new Map<CollectorPackage, CollectorLink[]>();
|
||||
const appendToPackage = (pkg: CollectorPackage, link: CollectorLink): void => {
|
||||
const links = appendedLinks.get(pkg);
|
||||
if (links) links.push(link);
|
||||
else appendedLinks.set(pkg, [link]);
|
||||
};
|
||||
const incomingUrls = new Set<string>();
|
||||
let addedLinks = 0;
|
||||
let duplicateLinks = 0;
|
||||
let enrichedLinks = 0;
|
||||
|
||||
for (const incomingPackage of incoming) {
|
||||
for (const incomingLink of incomingPackage.links) {
|
||||
const urlKey = collectorUrlKey(incomingLink.url);
|
||||
if (!urlKey || incomingUrls.has(urlKey)) {
|
||||
duplicateLinks += 1;
|
||||
continue;
|
||||
}
|
||||
incomingUrls.add(urlKey);
|
||||
const existing = existingByUrl.get(urlKey);
|
||||
const preserveExplicitPackage = existing?.pkg.nameSource === "explicit" && incomingPackage.nameSource === "inferred";
|
||||
const preserveRicherPackage = Boolean(existing
|
||||
&& incomingPackage.nameSource === "inferred"
|
||||
&& incomingCollectorMetadataDegrades(existing.link, incomingLink));
|
||||
const preserveExistingPackage = preserveExplicitPackage || preserveRicherPackage;
|
||||
const incomingPackageKey = packageNameKey(preserveExistingPackage && existing ? existing.pkg.name : incomingPackage.name);
|
||||
const upgradePackageIdentity = existing?.pkg.nameSource === "inferred" && incomingPackage.nameSource === "explicit";
|
||||
if (existing
|
||||
&& packageNameKey(existing.pkg.name) === incomingPackageKey
|
||||
&& !upgradePackageIdentity
|
||||
&& sameCollectorMetadata(existing.link, incomingLink)) {
|
||||
duplicateLinks += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = packageByName.get(incomingPackageKey);
|
||||
if (!target) {
|
||||
target = {
|
||||
...incomingPackage,
|
||||
name: preserveExistingPackage && existing ? existing.pkg.name : incomingPackage.name,
|
||||
nameSource: preserveExistingPackage && existing ? existing.pkg.nameSource : incomingPackage.nameSource,
|
||||
links: [],
|
||||
addedAt: incomingPackage.addedAt
|
||||
};
|
||||
packages.push(target);
|
||||
packageByName.set(incomingPackageKey, target);
|
||||
}
|
||||
if (incomingPackage.nameSource === "explicit") target.nameSource = "explicit";
|
||||
|
||||
if (existing) {
|
||||
const enriched = mergeCollectorLinkMetadata(existing.link, incomingLink);
|
||||
if (target === existing.pkg) replacements.set(existing.link, enriched);
|
||||
else {
|
||||
movedLinks.add(existing.link);
|
||||
appendToPackage(target, enriched);
|
||||
}
|
||||
target.addedAt = Math.min(target.addedAt, enriched.addedAt);
|
||||
existingByUrl.set(urlKey, { pkg: target, link: enriched });
|
||||
enrichedLinks += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const added = { ...incomingLink };
|
||||
appendToPackage(target, added);
|
||||
target.addedAt = Math.min(target.addedAt, added.addedAt);
|
||||
existingByUrl.set(urlKey, { pkg: target, link: added });
|
||||
addedLinks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packages: packages.flatMap((pkg) => {
|
||||
const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]);
|
||||
links.push(...(appendedLinks.get(pkg) ?? []));
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
}),
|
||||
addedLinks,
|
||||
duplicateLinks,
|
||||
enrichedLinks
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCollectorEnrichment(current: CollectorPackage[], incoming: CollectorPackage[]): CollectorMergeResult {
|
||||
const currentUrls = new Set(current.flatMap((pkg) => pkg.links.map((link) => collectorUrlKey(link.url))));
|
||||
const retained = incoming.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => currentUrls.has(collectorUrlKey(link.url)));
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
});
|
||||
return mergeCollectorPackages(current, retained);
|
||||
}
|
||||
|
||||
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,
|
||||
analyzing: boolean,
|
||||
selectedIds: string[],
|
||||
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 availabilityCounts: Record<CollectorAvailability, number> = { online: 0, unknown: 0, offline: 0 };
|
||||
for (const link of allLinks) availabilityCounts[link.availability] += 1;
|
||||
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;
|
||||
let totalBytes = 0;
|
||||
let unknownSizeCount = 0;
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
let unknownCount = 0;
|
||||
let selectedCount = 0;
|
||||
const hosters = new Set<string>();
|
||||
for (const link of pkg.links) {
|
||||
if (link.fileSizeBytes === null) unknownSizeCount += 1;
|
||||
else totalBytes += link.fileSizeBytes;
|
||||
if (link.availability === "online") onlineCount += 1;
|
||||
else if (link.availability === "offline") offlineCount += 1;
|
||||
else unknownCount += 1;
|
||||
if (selected.has(link.id)) selectedCount += 1;
|
||||
if (link.hoster) hosters.add(link.hoster);
|
||||
}
|
||||
rows.push({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
links: visibleLinks,
|
||||
allLinks: pkg.links,
|
||||
totalBytes,
|
||||
unknownSizeCount,
|
||||
onlineCount,
|
||||
offlineCount,
|
||||
unknownCount,
|
||||
totalCount: pkg.links.length,
|
||||
selectedCount,
|
||||
collapsed: collapsed.has(pkg.id),
|
||||
addedAt: pkg.addedAt,
|
||||
hosters: [...hosters]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
packages: rows,
|
||||
filters: [
|
||||
{ id: "all", label: "Alle", count: allLinks.length },
|
||||
{ id: "online", label: "Online", count: availabilityCounts.online },
|
||||
{ id: "unknown", label: "Ungeprüft", count: availabilityCounts.unknown },
|
||||
{ id: "offline", label: "Offline", count: availabilityCounts.offline }
|
||||
],
|
||||
filter,
|
||||
query,
|
||||
selectedIds,
|
||||
analyzing,
|
||||
error,
|
||||
empty: rows.length === 0,
|
||||
error
|
||||
totalCount: allLinks.length,
|
||||
selectedCount: allLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0),
|
||||
selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id),
|
||||
animationsEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,318 +1,490 @@
|
||||
.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-view {
|
||||
display: grid;
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
min-height: 520px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
gap: var(--collector-toolbar-action-gap);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-toolbar .ui-toolbar-group {
|
||||
gap: var(--collector-toolbar-action-gap);
|
||||
}
|
||||
|
||||
.collector-toolbar-tail {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.collector-toolbar-tail .ui-toolbar-search {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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-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-background-state,
|
||||
.collector-background-error {
|
||||
align-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex: 0 0 32px;
|
||||
font-size: 12px;
|
||||
gap: 7px;
|
||||
margin: 6px 8px 0;
|
||||
min-height: 32px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.collector-background-state {
|
||||
background: color-mix(in srgb, var(--ui-surface) 88%, transparent);
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
.collector-background-state span {
|
||||
animation: collector-analysis-pulse 1s ease-in-out infinite alternate;
|
||||
background: var(--ui-primary);
|
||||
border-radius: 50%;
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.collector-background-error {
|
||||
background: color-mix(in srgb, var(--ui-danger) 16%, var(--ui-surface));
|
||||
color: var(--ui-danger-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes collector-analysis-pulse {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.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, 0.8fr) minmax(90px, 0.65fr) minmax(115px, 0.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: 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);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
height: 41px;
|
||||
letter-spacing: 0.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(0.2, 0.8, 0.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-hoster-label {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
}
|
||||
|
||||
.collector-hoster-icon {
|
||||
display: block;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.collector-hoster-icon[data-hoster="rapidgator"] {
|
||||
transform: translateY(-4px) scale(2);
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.collector-dialog-secondary {
|
||||
background: var(--ui-modal-secondary);
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.collector-background-state span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@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, 0.8fr) minmax(78px, 0.65fr) minmax(100px, 0.9fr) minmax(112px, 1fr) minmax(132px, 1fr);
|
||||
min-width: 820px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.collector-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collector-toolbar .ui-toolbar-search {
|
||||
.collector-toolbar-tail {
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-toolbar-tail .ui-toolbar-search {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
||||
min-width: 610px;
|
||||
}
|
||||
}
|
||||
.collector-package-row,
|
||||
.collector-file-row {
|
||||
grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, 0.8fr) minmax(72px, 0.65fr) minmax(94px, 0.9fr) minmax(106px, 1fr) minmax(124px, 1fr);
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user