import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type 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 { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar"; import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT } from "../downloads/download-virtualizer"; import { getCollectorDisclosurePinnedIds, getCollectorDisclosureViewportIds, getCollectorFocusPackageId, mergeCollectorPinnedIds, resolveCollectorTransitionPins } from "./collector-disclosure"; import type { CollectorWorkspaceFilter, CollectorWorkspacePackageRow, CollectorWorkspaceViewModel } from "./collector-model"; import "./collector.css"; const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; 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 { if (row.offlineCount === row.totalCount) return "Offline"; if (row.onlineCount === row.totalCount) return "Online"; if (row.onlineCount > 0) return "Teilweise online"; 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 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 { if (availability === "online") return "Online"; if (availability === "offline") return "Offline"; return "Ungeprüft"; } export function collectorPackageIntrinsicBlockSize(row: CollectorWorkspacePackageRow): number { return 46 + (row.collapsed ? 0 : row.links.length * 40); } export function collectorFileInteractionAttributes( rowIndexStart: number, focusIndexStart: number, linkIndex: number, collapsed: boolean ): { rowIndex: number | undefined; focusIndex: number | undefined; tabIndex: number | undefined } { return collapsed ? { rowIndex: undefined, focusIndex: undefined, tabIndex: -1 } : { rowIndex: rowIndexStart + linkIndex + 1, focusIndex: focusIndexStart + linkIndex + 2, tabIndex: undefined }; } interface CollectorViewportState { scrollLeft: number; scrollTop: number; viewportHeight: number; } interface CollectorVirtualPackage { focusCount: number; focusIndexStart: number; id: string; height: number; rowIndex: number; row: CollectorWorkspacePackageRow; } function useCollectorViewport(bodyRef: React.RefObject): CollectorViewportState { const [viewport, setViewport] = useState({ scrollLeft: 0, scrollTop: 0, viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT }); useEffect(() => { const body = bodyRef.current; if (!body) return; let frame = 0; const measure = (): void => { frame = 0; const next = { scrollLeft: body.scrollLeft, scrollTop: body.scrollTop, viewportHeight: body.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT }; setViewport((current) => current.scrollLeft === next.scrollLeft && current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next); }; const schedule = (): void => { if (frame !== 0) return; frame = window.requestAnimationFrame(measure); }; measure(); body.addEventListener("scroll", schedule, { passive: true }); window.addEventListener("resize", schedule); return () => { body.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); if (frame !== 0) window.cancelAnimationFrame(frame); }; }, [bodyRef]); 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`, "--collector-virtual-package-height": `${height}px` } as CSSProperties; } export function toggleAllCollectorPackageIds( packageIds: readonly string[], collapsedPackageIds: ReadonlySet ): Set { return packageIds.some((packageId) => !collapsedPackageIds.has(packageId)) ? new Set(packageIds) : new Set(); } function CollectorHosterLabel({ hoster }: { hoster: ReturnType }): ReactElement { return ( {hoster.iconSrc ? ( <> { event.currentTarget.hidden = true; event.currentTarget.nextElementSibling?.removeAttribute("hidden"); }} src={hoster.iconSrc} /> ) : hoster.compact} ); } export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement { return (
Status{model.totalCount}
{model.filters.map((filter) => ( ))}
); } export function CollectorSidebarStatus({ model }: { model: CollectorWorkspaceViewModel }): ReactElement { return ( <> Pakete: {model.packageCount} Links: {model.totalCount} Ausgewählt: {model.selectedCount} {model.analyzing ? ( ) : null} ); } export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement { return ( actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} /> ); } function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, rowIndexStart }: { focusIndexStart: number; row: CollectorWorkspacePackageRow; model: CollectorWorkspaceViewModel; actions: CollectorViewActions; selected: ReadonlySet; rowIndexStart: number; }): ReactElement { const allSelected = row.selectedCount === row.totalCount; const partiallySelected = row.selectedCount > 0 && !allSelected; const animateItems = model.animationsEnabled && row.allLinks.length <= 64; const [renderItems, setRenderItems] = useState(!row.collapsed); const [expanding, setExpanding] = useState(false); const previousCollapsedRef = useRef(row.collapsed); const packageStyle: CSSProperties = { containIntrinsicBlockSize: `auto ${collectorPackageIntrinsicBlockSize(row)}px`, flex: "0 0 auto" }; useEffect(() => { const changed = previousCollapsedRef.current !== row.collapsed; previousCollapsedRef.current = row.collapsed; if (!changed) return; if (!row.collapsed) { setRenderItems(true); if (!animateItems) { setExpanding(false); return; } setExpanding(true); const timer = window.setTimeout(() => setExpanding(false), 300); return () => window.clearTimeout(timer); } setExpanding(false); if (!animateItems) { setRenderItems(false); return; } const timer = window.setTimeout(() => setRenderItems(false), 300); return () => window.clearTimeout(timer); }, [animateItems, row.collapsed]); const animateDisclosure = animateItems && (row.collapsed || expanding); return (
0 ? " is-selected" : ""}`} role="row"> actions.onPackageSelectionChange(row.id, event.target.checked)} ref={(node) => { if (node) node.indeterminate = partiallySelected; }} type="checkbox" /> {row.name} {row.totalCount} Dateien {packageSize(row)} {row.hosters.map(formatHosterLabel).map((hoster) => )} {packageStatus(row)} {packageAvailability(row)} {formatDateTime(row.addedAt)}
{renderItems ? (
{row.links.map((link, linkIndex) => { const hoster = formatHosterLabel(link.hoster); const interaction = collectorFileInteractionAttributes(rowIndexStart, focusIndexStart, linkIndex, row.collapsed); return (
actions.onLinkSelectionChange(link.id, event.target.checked)} tabIndex={interaction.tabIndex} type="checkbox" /> {link.fileName} {link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)} {linkAvailability(link.availability)} {linkAvailability(link.availability)} {formatDateTime(link.addedAt)}
); })}
) : null}
); } export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement { const selected = new Set(model.selectedIds); const bodyRef = useRef(null); const viewport = useCollectorViewport(bodyRef); const virtualPackages = useMemo(() => { let focusIndex = 0; let rowIndex = 2; return model.packages.map((row) => { const focusCount = 2 + (row.collapsed ? 0 : row.links.length); const entry = { focusCount, focusIndexStart: focusIndex, id: row.id, height: collectorPackageIntrinsicBlockSize(row), rowIndex, row }; focusIndex += focusCount; rowIndex += 1 + (row.collapsed ? 0 : row.links.length); return entry; }); }, [model.packages]); const logicalRowCount = 1 + virtualPackages.reduce((count, entry) => count + 1 + (entry.row.collapsed ? 0 : entry.row.links.length), 0); const logicalFocusCount = virtualPackages.reduce((count, entry) => count + entry.focusCount, 0); const previousCollapsedRef = useRef(new Map(model.packages.map((row) => [row.id, row.collapsed]))); const previousVisibleIdsRef = useRef([]); const transitionTimerRef = useRef(0); const [transitionPinnedIds, setTransitionPinnedIds] = useState([]); const [focusedPackageId, setFocusedPackageId] = useState(null); const [pendingFocusIndex, setPendingFocusIndex] = useState(null); const freshPinnedIds = getCollectorDisclosurePinnedIds( previousVisibleIdsRef.current, previousCollapsedRef.current, model.packages, model.animationsEnabled ); const effectivePinnedIds = mergeCollectorPinnedIds(resolveCollectorTransitionPins(transitionPinnedIds, freshPinnedIds), focusedPackageId); const stateOffset = model.error ? 38 : 0; const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(virtualPackages, { scrollTop: Math.max(0, viewport.scrollTop - stateOffset), viewportHeight: viewport.viewportHeight, overscan: 2, pinnedIds: effectivePinnedIds }), [effectivePinnedIds.join("\u0000"), stateOffset, viewport.scrollTop, viewport.viewportHeight, virtualPackages]); const collapsedById = new Map(model.packages.map((row) => [row.id, row.collapsed])); const freshPinnedKey = freshPinnedIds.map((id) => `${id}:${collapsedById.get(id) ? 1 : 0}`).join("\u0000"); useRendererLayoutEffect(() => { if (freshPinnedIds.length === 0) return; setTransitionPinnedIds((current) => resolveCollectorTransitionPins(current, freshPinnedIds)); if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current); transitionTimerRef.current = window.setTimeout(() => { transitionTimerRef.current = 0; setTransitionPinnedIds([]); }, 320); }, [freshPinnedKey]); useRendererLayoutEffect(() => { previousCollapsedRef.current = new Map(model.packages.map((row) => [row.id, row.collapsed])); previousVisibleIdsRef.current = getCollectorDisclosureViewportIds(virtualWindow.rows, virtualWindow.startIndex, virtualWindow.endIndex); }, [model.packages, virtualWindow.rows]); useRendererLayoutEffect(() => { if (pendingFocusIndex === null) return; const target = bodyRef.current?.querySelector(`[data-collector-focus-index="${pendingFocusIndex}"]`); if (!target) return; target.focus(); target.scrollIntoView({ block: "nearest" }); setPendingFocusIndex(null); }, [pendingFocusIndex, virtualWindow.rows]); useEffect(() => () => { if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current); }, []); const spacerStyle = { "--collector-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties; return (
Name Datei Größe Hoster Status Verfügbarkeit Hinzugefügt
{ if (event.key !== "Tab") return; const currentIndex = Number((event.target as HTMLElement).dataset.collectorFocusIndex); if (!Number.isInteger(currentIndex)) return; const nextIndex = currentIndex + (event.shiftKey ? -1 : 1); if (nextIndex < 0 || nextIndex >= logicalFocusCount) return; const packageId = getCollectorFocusPackageId(virtualPackages, nextIndex); if (!packageId) return; event.preventDefault(); setFocusedPackageId(packageId); setPendingFocusIndex(nextIndex); }} ref={bodyRef} > {model.error ?
{model.error}
: null} {model.empty ? ( ) : (
{virtualWindow.rows.map((entry) => (
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setFocusedPackageId((current) => current === entry.id ? null : current); } }} onFocusCapture={() => setFocusedPackageId(entry.id)} style={collectorVirtualPackageStyle(entry.top, entry.height)} >
))}
)}
); } export const MemoizedCollectorContent = memo( CollectorContent, (previous, next) => previous.model === next.model ); export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement { if (region === "sidebar") return ; if (region === "toolbar") return ; if (region === "content") return ; return (
); } export function CollectorInputDialog({ open, value, onChange, onClose, onCommit }: CollectorInputDialogProps): ReactElement | null { return ( )} description="Links erscheinen sofort und werden anschließend im Hintergrund geprüft." onClose={onClose} open={open} size="wide" title="Links hinzufügen" >