Improve collector metadata progress and alignment

Stream completed metadata checks into the collector while preserving generation-based stale-result protection. Derive row statuses from verified availability, move analysis feedback into the sidebar, center table values under their headers, and keep the header synchronized during horizontal scrolling.
This commit is contained in:
Sucukdeluxe
2026-08-26 16:05:09 +02:00
parent 0ba87c37ec
commit eed8d1e970
15 changed files with 309 additions and 57 deletions
+32 -13
View File
@@ -48,10 +48,9 @@ export interface CollectorInputDialogProps {
}
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`;
if (row.onlineCount === row.totalCount) return "Online";
if (row.onlineCount > 0) return "Teilweise online";
return "Ungeprüft";
}
@@ -73,10 +72,11 @@ function availabilityClass(row: CollectorWorkspacePackageRow): string {
return "unknown";
}
function linkStatus(status: "ready" | "offline" | "unknown"): string {
if (status === "ready") return "Bereit";
if (status === "offline") return "Offline";
return "Ungeprüft";
function availabilityTone(online: number, offline: number, total: number): "online" | "offline" | "partial" | "unknown" {
if (online === total) return "online";
if (offline === total) return "offline";
if (online > 0) return "partial";
return "unknown";
}
function linkAvailability(availability: "online" | "offline" | "unknown"): string {
@@ -101,6 +101,7 @@ export function collectorFileInteractionAttributes(
}
interface CollectorViewportState {
scrollLeft: number;
scrollTop: number;
viewportHeight: number;
}
@@ -116,6 +117,7 @@ interface CollectorVirtualPackage {
function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): CollectorViewportState {
const [viewport, setViewport] = useState<CollectorViewportState>({
scrollLeft: 0,
scrollTop: 0,
viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
});
@@ -126,10 +128,11 @@ function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): Collect
const measure = (): void => {
frame = 0;
const next = {
scrollLeft: body.scrollLeft,
scrollTop: body.scrollTop,
viewportHeight: body.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
};
setViewport((current) => current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next);
setViewport((current) => current.scrollLeft === next.scrollLeft && current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next);
};
const schedule = (): void => {
if (frame !== 0) return;
@@ -147,6 +150,10 @@ function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): Collect
return viewport;
}
export function collectorHeaderScrollStyle(scrollLeft: number): CSSProperties {
return { transform: `translateX(${-Math.max(0, scrollLeft)}px)` };
}
function collectorVirtualPackageStyle(top: number, height: number): CSSProperties {
return {
"--collector-virtual-package-top": `${top}px`,
@@ -208,6 +215,19 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
);
}
export function CollectorSidebarStatus({ model }: { model: CollectorWorkspaceViewModel }): ReactElement {
return (
<>
<span>Pakete: {model.packageCount}</span>
<span>Links: {model.totalCount}</span>
<span>Ausgewählt: {model.selectedCount}</span>
{model.analyzing ? (
<span aria-live="polite" className="collector-sidebar-analysis" role="status"><span aria-hidden="true" />Analyse läuft im Hintergrund</span>
) : null}
</>
);
}
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
return (
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
@@ -300,7 +320,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
<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-status-cell is-${availabilityTone(row.onlineCount, row.offlineCount, row.totalCount)}`} 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>
@@ -318,7 +338,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
<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-status-cell is-${link.availability}`} role="cell">{linkAvailability(link.availability)}</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>
@@ -368,7 +388,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
model.animationsEnabled
);
const effectivePinnedIds = mergeCollectorPinnedIds(resolveCollectorTransitionPins(transitionPinnedIds, freshPinnedIds), focusedPackageId);
const stateOffset = (model.analyzing ? 38 : 0) + (model.error ? 38 : 0);
const stateOffset = model.error ? 38 : 0;
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(virtualPackages, {
scrollTop: Math.max(0, viewport.scrollTop - stateOffset),
viewportHeight: viewport.viewportHeight,
@@ -406,7 +426,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
<section className="collector-content" aria-label="Gesammelte Downloadpakete">
<DataTable aria-rowcount={logicalRowCount} className="collector-table" label="Gesammelte Downloadpakete">
<DataTableHeader className="collector-table-header">
<div aria-rowindex={1} className="collector-table-header-row" role="row">
<div aria-rowindex={1} className="collector-table-header-row" role="row" style={collectorHeaderScrollStyle(viewport.scrollLeft)}>
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Name</span>
<span role="columnheader">Größe</span>
@@ -433,7 +453,6 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
}}
ref={bodyRef}
>
{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
@@ -28,6 +28,7 @@ export interface CollectorWorkspacePackageRow {
export interface CollectorWorkspaceViewModel {
packages: CollectorWorkspacePackageRow[];
packageCount: number;
filters: CollectorWorkspaceFilterEntry[];
filter: CollectorWorkspaceFilter;
query: string;
@@ -276,6 +277,7 @@ export function buildCollectorWorkspaceViewModel(
return {
packages: rows,
packageCount: packages.length,
filters: [
{ id: "all", label: "Alle", count: allLinks.length },
{ id: "online", label: "Online", count: availabilityCounts.online },
+45 -8
View File
@@ -141,7 +141,6 @@
height: 100%;
}
.collector-background-state,
.collector-background-error {
align-items: center;
backdrop-filter: blur(8px);
@@ -156,14 +155,15 @@
padding: 0 11px;
}
.collector-background-state {
background: color-mix(in srgb, var(--ui-surface) 88%, transparent);
color: var(--ui-text-secondary);
.collector-sidebar-analysis {
align-items: center;
display: flex;
gap: 6px;
}
.collector-background-state span {
.collector-sidebar-analysis > span {
animation: collector-analysis-pulse 1s ease-in-out infinite alternate;
background: var(--ui-primary);
background: var(--ui-success);
border-radius: 50%;
height: 7px;
width: 7px;
@@ -184,6 +184,12 @@
.collector-table-header {
height: 41px;
overflow: hidden;
}
.collector-table-header,
.collector-table-body {
scrollbar-gutter: stable;
}
.collector-table-header-row,
@@ -211,6 +217,25 @@
padding: 0 12px;
}
.collector-table-header-row > span:nth-child(2) {
padding-left: 48px;
}
.collector-table-header-row > span:nth-child(n+3) {
display: flex;
justify-content: center;
text-align: center;
}
.collector-size-cell,
.collector-hoster-cell,
.collector-status-cell,
.collector-availability-cell,
.collector-added-cell {
justify-content: center;
text-align: center;
}
.collector-table-body {
overflow: auto;
}
@@ -326,7 +351,7 @@
.collector-name-cell.is-file {
color: var(--ui-text);
padding-left: 36px;
padding-left: 32px;
}
.collector-collapse-button {
@@ -404,6 +429,18 @@
color: var(--ui-text-muted);
}
.collector-status-cell.is-online {
color: var(--ui-success);
}
.collector-status-cell.is-offline {
color: var(--ui-danger);
}
.collector-status-cell.is-partial {
color: var(--ui-warning-text);
}
.collector-availability-cell {
font-weight: 600;
white-space: nowrap;
@@ -467,7 +504,7 @@
}
@media (prefers-reduced-motion: reduce) {
.collector-background-state span {
.collector-sidebar-analysis > span {
animation: none;
}