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:
+259
-304
@@ -61,9 +61,17 @@ import { Dialog } from "./ui/Dialog";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { Toast } from "./ui/Toast";
|
||||
import { LinkAddressesDialog } from "./ui/LinkAddressesDialog";
|
||||
import { serializeCollectorPackages, type CollectorInspectionResult, type CollectorPackage } from "../shared/collector";
|
||||
import { routeDroppedDlcFiles } from "./collector-drop";
|
||||
import { beginCollectorEnrichment, filterCurrentCollectorEnrichment } from "./collector-enrichment";
|
||||
import {
|
||||
buildCollectorViewModel,
|
||||
type CollectorSourceTab
|
||||
buildCollectorTransferPackages,
|
||||
buildCollectorWorkspaceViewModel,
|
||||
mergeCollectorEnrichment,
|
||||
mergeCollectorPackages,
|
||||
removeCollectorLinks,
|
||||
selectCollectorPackageLinks,
|
||||
type CollectorWorkspaceFilter
|
||||
} from "./views/collector/collector-model";
|
||||
import {
|
||||
CollectorContent,
|
||||
@@ -140,65 +148,10 @@ import {
|
||||
} from "./views/settings/SettingsView";
|
||||
|
||||
type Tab = MainView;
|
||||
|
||||
type CollectorTab = CollectorSourceTab;
|
||||
|
||||
interface CollectorInputState {
|
||||
tabId: string;
|
||||
tabName: string;
|
||||
baseText: string;
|
||||
draft: string;
|
||||
}
|
||||
export function mergeCollectorDraftText(baseText: string, currentText: string, draft: string): string {
|
||||
if (currentText === baseText) {
|
||||
return draft;
|
||||
}
|
||||
const appended = currentText.startsWith(baseText) ? currentText.slice(baseText.length) : currentText;
|
||||
if (!appended) {
|
||||
return draft;
|
||||
}
|
||||
const normalizedAppend = appended.replace(/^\r?\n/, "");
|
||||
if (!draft) {
|
||||
return normalizedAppend;
|
||||
}
|
||||
if (!normalizedAppend) {
|
||||
return draft;
|
||||
}
|
||||
return `${draft}${draft.endsWith("\n") ? "" : "\n"}${normalizedAppend}`;
|
||||
}
|
||||
|
||||
export function planCollectorTabRemoval(
|
||||
tabs: CollectorTab[],
|
||||
activeTabId: string,
|
||||
removedTabId: string
|
||||
): { tabs: CollectorTab[]; activeTabId: string } {
|
||||
if (tabs.length <= 1) {
|
||||
return { tabs, activeTabId };
|
||||
}
|
||||
const removedIndex = tabs.findIndex((tab) => tab.id === removedTabId);
|
||||
if (removedIndex < 0) {
|
||||
return {
|
||||
tabs,
|
||||
activeTabId: tabs.some((tab) => tab.id === activeTabId) ? activeTabId : (tabs[0]?.id ?? "")
|
||||
};
|
||||
}
|
||||
const nextTabs = tabs.filter((tab) => tab.id !== removedTabId);
|
||||
const nextActiveTabId = activeTabId === removedTabId
|
||||
? (nextTabs[Math.max(0, removedIndex - 1)]?.id ?? nextTabs[0]?.id ?? "")
|
||||
: (nextTabs.some((tab) => tab.id === activeTabId) ? activeTabId : (nextTabs[0]?.id ?? ""));
|
||||
return { tabs: nextTabs, activeTabId: nextActiveTabId };
|
||||
}
|
||||
|
||||
export function planCollectorTextReplacement(
|
||||
tabs: CollectorTab[],
|
||||
tabId: string,
|
||||
text: string
|
||||
): { tabs: CollectorTab[]; selectedIds: string[] } {
|
||||
return {
|
||||
tabs: tabs.map((tab) => tab.id === tabId ? { ...tab, text } : tab),
|
||||
selectedIds: []
|
||||
};
|
||||
}
|
||||
|
||||
interface StartConflictPromptState {
|
||||
entry: StartConflictEntry;
|
||||
@@ -1434,9 +1387,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
|
||||
);
|
||||
});
|
||||
|
||||
let nextCollectorId = 1;
|
||||
|
||||
function createScheduleId(): string {
|
||||
function createScheduleId(): string {
|
||||
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
@@ -1735,16 +1686,17 @@ export function App(): ReactElement {
|
||||
const [providerDropTarget, setProviderDropTarget] = useState<DebridProvider | null>(null);
|
||||
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([
|
||||
{ id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" }
|
||||
]);
|
||||
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
|
||||
const [collectorPackages, setCollectorPackages] = useState<CollectorPackage[]>([]);
|
||||
const [collectorFilter, setCollectorFilter] = useState<CollectorWorkspaceFilter>("all");
|
||||
const [collectorQuery, setCollectorQuery] = useState("");
|
||||
const [selectedCollectorRowIds, setSelectedCollectorRowIds] = useState<Set<string>>(() => new Set());
|
||||
const [selectedCollectorLinkIds, setSelectedCollectorLinkIds] = useState<Set<string>>(() => new Set());
|
||||
const [collapsedCollectorPackageIds, setCollapsedCollectorPackageIds] = useState<Set<string>>(() => new Set());
|
||||
const [collectorAnalyzingCount, setCollectorAnalyzingCount] = useState(0);
|
||||
const [collectorError, setCollectorError] = useState("");
|
||||
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
|
||||
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
|
||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||
const collectorPackagesRef = useRef<CollectorPackage[]>(collectorPackages);
|
||||
const collectorEnrichmentGenerationsRef = useRef(new Map<string, number>());
|
||||
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
|
||||
const activeTabRef = useRef<Tab>(tab);
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
@@ -1864,14 +1816,16 @@ export function App(): ReactElement {
|
||||
columnOrderPersistenceRef.current?.enqueue(order);
|
||||
}, []);
|
||||
|
||||
const collectorViewModel = useMemo(() => buildCollectorViewModel(
|
||||
collectorTabs,
|
||||
activeCollectorTab,
|
||||
const collectorViewModel = useMemo(() => buildCollectorWorkspaceViewModel(
|
||||
collectorPackages,
|
||||
collectorFilter,
|
||||
collectorQuery,
|
||||
actionBusy,
|
||||
[...selectedCollectorRowIds],
|
||||
collectorError
|
||||
), [actionBusy, activeCollectorTab, collectorError, collectorQuery, collectorTabs, selectedCollectorRowIds]);
|
||||
collectorAnalyzingCount > 0,
|
||||
[...selectedCollectorLinkIds],
|
||||
[...collapsedCollectorPackageIds],
|
||||
collectorError,
|
||||
snapshot.settings.animatePackageDisclosure
|
||||
), [collapsedCollectorPackageIds, collectorAnalyzingCount, collectorError, collectorFilter, collectorPackages, collectorQuery, selectedCollectorLinkIds, snapshot.settings.animatePackageDisclosure]);
|
||||
|
||||
const historyViewModel = useMemo(() => buildHistoryViewModel(
|
||||
historyEntries,
|
||||
@@ -1890,13 +1844,7 @@ export function App(): ReactElement {
|
||||
[runtimeNow, snapshot, statisticsRange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeCollectorTabRef.current = activeCollectorTab;
|
||||
}, [activeCollectorTab]);
|
||||
|
||||
useEffect(() => {
|
||||
collectorTabsRef.current = collectorTabs;
|
||||
}, [collectorTabs]);
|
||||
collectorPackagesRef.current = collectorPackages;
|
||||
|
||||
useEffect(() => {
|
||||
activeTabRef.current = tab;
|
||||
@@ -2235,16 +2183,11 @@ export function App(): ReactElement {
|
||||
latestStateRef.current = null;
|
||||
}
|
||||
}, flushDelay);
|
||||
});
|
||||
unsubClipboard = window.rd.onClipboardDetected((links) => {
|
||||
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
|
||||
setCollectorTabs((prev) => {
|
||||
const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0];
|
||||
if (!active) { return prev; }
|
||||
const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n");
|
||||
return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
|
||||
});
|
||||
});
|
||||
});
|
||||
unsubClipboard = window.rd.onClipboardDetected((links) => {
|
||||
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
|
||||
void importCollectorTextRef.current(links.join("\n"));
|
||||
});
|
||||
unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
@@ -3626,49 +3569,144 @@ export function App(): ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const onAddLinks = async (): Promise<void> => {
|
||||
setCollectorError("");
|
||||
await performQuickAction(async () => {
|
||||
const activeId = activeCollectorTabRef.current;
|
||||
const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0];
|
||||
const rawText = active?.text ?? "";
|
||||
const persisted = await persistDraftSettings();
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName });
|
||||
if (result.addedLinks > 0) {
|
||||
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
|
||||
setCollectorTabs((prev) => planCollectorTextReplacement(prev, activeId, "").tabs);
|
||||
setSelectedCollectorRowIds(new Set());
|
||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
||||
} else {
|
||||
showToast("Keine gültigen Links gefunden");
|
||||
}
|
||||
}, (error) => {
|
||||
setCollectorError(`Fehler beim Hinzufügen: ${String(error)}`);
|
||||
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
|
||||
const mergeCollectorResult = (result: CollectorInspectionResult, enrichment = false): void => {
|
||||
setCollectorPackages((current) => {
|
||||
const merged = enrichment
|
||||
? mergeCollectorEnrichment(current, result.packages)
|
||||
: mergeCollectorPackages(current, result.packages);
|
||||
collectorPackagesRef.current = merged.packages;
|
||||
return merged.packages;
|
||||
});
|
||||
};
|
||||
|
||||
const onImportDlc = async (): Promise<void> => {
|
||||
const enrichCollectorResult = (packages: CollectorPackage[]): void => {
|
||||
if (packages.length === 0) {
|
||||
return;
|
||||
}
|
||||
const generations = beginCollectorEnrichment(packages, collectorEnrichmentGenerationsRef.current);
|
||||
setCollectorAnalyzingCount((current) => current + 1);
|
||||
void window.rd.enrichCollectorPackages({ packages }).then((result) => {
|
||||
mergeCollectorResult({
|
||||
...result,
|
||||
packages: filterCurrentCollectorEnrichment(
|
||||
result.packages,
|
||||
generations,
|
||||
collectorEnrichmentGenerationsRef.current
|
||||
)
|
||||
}, true);
|
||||
}).catch((error) => {
|
||||
if (filterCurrentCollectorEnrichment(packages, generations, collectorEnrichmentGenerationsRef.current).length > 0) {
|
||||
setCollectorError(`Metadatenprüfung fehlgeschlagen: ${String(error)}`);
|
||||
}
|
||||
}).finally(() => {
|
||||
setCollectorAnalyzingCount((current) => Math.max(0, current - 1));
|
||||
});
|
||||
};
|
||||
|
||||
const importCollectorText = async (rawText: string): Promise<void> => {
|
||||
if (!rawText.trim()) {
|
||||
showToast("Keine Links eingegeben", 2200);
|
||||
return;
|
||||
}
|
||||
setCollectorError("");
|
||||
setCollectorAnalyzingCount((current) => current + 1);
|
||||
try {
|
||||
const prepared = await window.rd.prepareCollectorText({ rawText, addedAt: Date.now() });
|
||||
if (prepared.packages.length === 0) {
|
||||
const message = "Keine gültigen Links gefunden";
|
||||
setCollectorError(message);
|
||||
showToast(message, 2600);
|
||||
return;
|
||||
}
|
||||
mergeCollectorResult(prepared);
|
||||
setCollectorFilter("all");
|
||||
setTab("collector");
|
||||
const linkCount = prepared.packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||
showToast(`${prepared.packages.length} Paket(e), ${linkCount} Link(s) gesammelt`);
|
||||
enrichCollectorResult(prepared.packages);
|
||||
} catch (error) {
|
||||
const message = `Links konnten nicht vorbereitet werden: ${String(error)}`;
|
||||
setCollectorError(message);
|
||||
showToast(message, 2800);
|
||||
} finally {
|
||||
setCollectorAnalyzingCount((current) => Math.max(0, current - 1));
|
||||
}
|
||||
};
|
||||
|
||||
importCollectorTextRef.current = importCollectorText;
|
||||
|
||||
const importCollectorContainers = async (filePaths: string[]): Promise<CollectorInspectionResult | null> => {
|
||||
if (filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
setCollectorError("");
|
||||
setCollectorAnalyzingCount((current) => current + 1);
|
||||
try {
|
||||
const prepared = await window.rd.prepareCollectorContainers(filePaths, Date.now());
|
||||
if (prepared.packages.length === 0) {
|
||||
const message = "Keine gültigen Links in den DLC-Dateien gefunden";
|
||||
setCollectorError(message);
|
||||
showToast(message, 3000);
|
||||
return prepared;
|
||||
}
|
||||
mergeCollectorResult(prepared);
|
||||
setCollectorFilter("all");
|
||||
setTab("collector");
|
||||
const linkCount = prepared.packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||
showToast(`DLC gesammelt: ${prepared.packages.length} Paket(e), ${linkCount} Link(s)`);
|
||||
enrichCollectorResult(prepared.packages);
|
||||
return prepared;
|
||||
} catch (error) {
|
||||
const message = `Fehler beim DLC-Import: ${String(error)}`;
|
||||
setCollectorError(message);
|
||||
showToast(message, 2800);
|
||||
return null;
|
||||
} finally {
|
||||
setCollectorAnalyzingCount((current) => Math.max(0, current - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const onImportDlc = async (): Promise<void> => {
|
||||
const files = await window.rd.pickContainers();
|
||||
if (files.length > 0) {
|
||||
await importCollectorContainers(files);
|
||||
}
|
||||
};
|
||||
|
||||
const submitCollectorPackages = async (packages: CollectorPackage[]): Promise<void> => {
|
||||
const transferable = packages.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => link.availability !== "offline");
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
});
|
||||
const linkIds = new Set(transferable.flatMap((pkg) => pkg.links.map((link) => link.id)));
|
||||
if (linkIds.size === 0) {
|
||||
showToast("Keine übertragbaren Links ausgewählt", 2400);
|
||||
return;
|
||||
}
|
||||
await performQuickAction(async () => {
|
||||
const files = await window.rd.pickContainers();
|
||||
if (files.length === 0) { return; }
|
||||
await persistDraftSettings();
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
const result = await window.rd.addContainers(files);
|
||||
if (result.addedLinks > 0) {
|
||||
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
||||
} else {
|
||||
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
|
||||
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
const result = await window.rd.addLinks({ rawText: serializeCollectorPackages(transferable), packageName: "" });
|
||||
if (result.addedLinks !== linkIds.size) {
|
||||
showToast(`${result.addedLinks} von ${linkIds.size} Link(s) übergeben; Sammlung bleibt erhalten`, 3200);
|
||||
return;
|
||||
}
|
||||
setCollectorPackages((current) => {
|
||||
const next = removeCollectorLinks(current, linkIds);
|
||||
collectorPackagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setSelectedCollectorLinkIds((current) => new Set([...current].filter((id) => !linkIds.has(id))));
|
||||
setTab("downloads");
|
||||
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) übergeben`);
|
||||
if (snapshotRef.current.settings.collapseNewPackages) {
|
||||
await collapseNewPackages(existingIds);
|
||||
}
|
||||
}, (error) => {
|
||||
setCollectorError(`Fehler beim DLC-Import: ${String(error)}`);
|
||||
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
|
||||
const message = `Übergabe fehlgeschlagen: ${String(error)}`;
|
||||
setCollectorError(message);
|
||||
showToast(message, 2800);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const onExportPackageSelection = async (packageIds: string[]): Promise<void> => {
|
||||
closeMenus();
|
||||
@@ -3702,61 +3740,43 @@ export function App(): ReactElement {
|
||||
dragOverRef.current = false;
|
||||
setDragOver(false);
|
||||
const hasFiles = event.dataTransfer.types.includes("Files");
|
||||
const hasUri = event.dataTransfer.types.includes("text/uri-list");
|
||||
if (!hasFiles && !hasUri) { return; }
|
||||
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
||||
const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v);
|
||||
const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name));
|
||||
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
|
||||
if (dlc.length > 0) {
|
||||
setCollectorError("");
|
||||
await performQuickAction(async () => {
|
||||
await persistDraftSettings();
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
const result = await window.rd.addContainers(dlc);
|
||||
if (result.addedLinks > 0) {
|
||||
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
||||
} else {
|
||||
setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden");
|
||||
const hasUri = event.dataTransfer.types.includes("text/uri-list");
|
||||
if (!hasFiles && !hasUri) { return; }
|
||||
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
||||
const hasDlc = files.some((file) => file.name.toLowerCase().endsWith(".dlc"));
|
||||
const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name));
|
||||
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
|
||||
if (hasDlc) {
|
||||
try {
|
||||
const mode = tabRef.current === "collector" ? "collector" : "downloads";
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
const routed = await routeDroppedDlcFiles(files, mode, window.rd.getPathForDroppedFile, {
|
||||
addContainers: window.rd.addContainers,
|
||||
inspectContainers: (filePaths) => importCollectorContainers(filePaths)
|
||||
});
|
||||
if (routed.kind === "empty") {
|
||||
showToast("DLC-Dateipfad konnte nicht gelesen werden", 2800);
|
||||
} else if (routed.kind === "downloads" && routed.result.addedLinks > 0) {
|
||||
setTab("downloads");
|
||||
showToast(`Drag-and-Drop: ${routed.result.addedPackages} Paket(e), ${routed.result.addedLinks} Link(s)`);
|
||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
||||
} else if (routed.kind === "downloads") {
|
||||
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
||||
}
|
||||
}, (error) => {
|
||||
setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`);
|
||||
} catch (error) {
|
||||
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
||||
});
|
||||
}
|
||||
} else if (importFiles.length > 0) {
|
||||
setCollectorError("");
|
||||
await performQuickAction(async () => {
|
||||
await persistDraftSettings();
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
let addedPackages = 0;
|
||||
let addedLinks = 0;
|
||||
for (const file of importFiles) {
|
||||
const text = await file.text();
|
||||
const result = await window.rd.importQueue(text);
|
||||
addedPackages += result.addedPackages;
|
||||
addedLinks += result.addedLinks;
|
||||
}
|
||||
if (addedLinks > 0) {
|
||||
showToast(`Importiert: ${addedPackages} Paket(e), ${addedLinks} Link(s)`);
|
||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
||||
} else {
|
||||
setCollectorError("Keine gültigen Links in den Import-Dateien gefunden");
|
||||
showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000);
|
||||
}
|
||||
}, (error) => {
|
||||
setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`);
|
||||
try {
|
||||
const text = (await Promise.all(importFiles.map((file) => file.text()))).join("\n");
|
||||
await importCollectorText(text);
|
||||
} catch (error) {
|
||||
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
||||
});
|
||||
} else if (droppedText.trim()) {
|
||||
const activeCollectorId = activeCollectorTabRef.current;
|
||||
setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId
|
||||
? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
|
||||
setTab("collector");
|
||||
showToast("Links per Drag-and-Drop eingefügt");
|
||||
}
|
||||
};
|
||||
}
|
||||
} else if (droppedText.trim()) {
|
||||
await importCollectorText(droppedText);
|
||||
}
|
||||
};
|
||||
|
||||
const onExportQueue = async (): Promise<void> => {
|
||||
await performQuickAction(async () => {
|
||||
@@ -3797,28 +3817,18 @@ export function App(): ReactElement {
|
||||
input.onchange = async () => {
|
||||
clearImportQueueFocusListener();
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
releasePickerBusy();
|
||||
return;
|
||||
}
|
||||
releasePickerBusy();
|
||||
await performQuickAction(async () => {
|
||||
await persistDraftSettings();
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
const text = await file.text();
|
||||
const result = await window.rd.importQueue(text);
|
||||
if (result.addedLinks > 0) {
|
||||
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
|
||||
} else {
|
||||
setCollectorError("Keine gültigen Links in der Datei gefunden");
|
||||
showToast("Keine gültigen Links in der Datei gefunden", 3000);
|
||||
}
|
||||
}, (error) => {
|
||||
setCollectorError(`Import fehlgeschlagen: ${String(error)}`);
|
||||
if (!file) {
|
||||
releasePickerBusy();
|
||||
return;
|
||||
}
|
||||
releasePickerBusy();
|
||||
try {
|
||||
const text = await file.text();
|
||||
await importCollectorText(text);
|
||||
} catch (error) {
|
||||
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
clearImportQueueFocusListener();
|
||||
importQueueFocusHandlerRef.current = onWindowFocus;
|
||||
@@ -3922,112 +3932,57 @@ export function App(): ReactElement {
|
||||
});
|
||||
}, [showToast]);
|
||||
|
||||
const addCollectorTab = (): void => {
|
||||
const id = `tab-${nextCollectorId++}`;
|
||||
setCollectorTabs((prev) => {
|
||||
const name = `Tab ${prev.length + 1}`;
|
||||
return [...prev, { id, name, text: "" }];
|
||||
});
|
||||
setActiveCollectorTab(id);
|
||||
setSelectedCollectorRowIds(new Set());
|
||||
setCollectorError("");
|
||||
};
|
||||
|
||||
const removeCollectorTab = (id: string): void => {
|
||||
const tab = collectorTabsRef.current.find((entry) => entry.id === id);
|
||||
if (!tab || collectorTabsRef.current.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const linkCount = tab.text.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
||||
void askConfirmPrompt({
|
||||
title: "Sammlung entfernen",
|
||||
message: linkCount > 0
|
||||
? `Soll die Sammlung ${tab.name} mit ${linkCount} Link(s) wirklich entfernt werden?`
|
||||
: `Soll die leere Sammlung ${tab.name} wirklich entfernt werden?`,
|
||||
confirmLabel: "Sammlung entfernen",
|
||||
danger: true
|
||||
}).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const removal = planCollectorTabRemoval(
|
||||
collectorTabsRef.current,
|
||||
activeCollectorTabRef.current,
|
||||
id
|
||||
);
|
||||
if (removal.tabs === collectorTabsRef.current) {
|
||||
return;
|
||||
}
|
||||
collectorTabsRef.current = removal.tabs;
|
||||
activeCollectorTabRef.current = removal.activeTabId;
|
||||
setCollectorTabs(removal.tabs);
|
||||
setActiveCollectorTab(removal.activeTabId);
|
||||
setSelectedCollectorRowIds(new Set());
|
||||
setCollectorError("");
|
||||
});
|
||||
};
|
||||
|
||||
const openCollectorInput = (): void => {
|
||||
const activeId = activeCollectorTabRef.current;
|
||||
const active = collectorTabsRef.current.find((entry) => entry.id === activeId) ?? collectorTabsRef.current[0];
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setCollectorError("");
|
||||
setCollectorInput({
|
||||
tabId: active.id,
|
||||
tabName: active.name,
|
||||
baseText: active.text,
|
||||
draft: active.text
|
||||
});
|
||||
setCollectorInput({ draft: "" });
|
||||
};
|
||||
|
||||
const commitCollectorInput = (): void => {
|
||||
if (!collectorInput) {
|
||||
return;
|
||||
}
|
||||
const input = collectorInput;
|
||||
setCollectorTabs((prev) => {
|
||||
const currentText = prev.find((entry) => entry.id === input.tabId)?.text ?? input.baseText;
|
||||
const text = mergeCollectorDraftText(input.baseText, currentText, input.draft);
|
||||
return planCollectorTextReplacement(prev, input.tabId, text).tabs;
|
||||
});
|
||||
setSelectedCollectorRowIds(new Set());
|
||||
const draft = collectorInput.draft;
|
||||
setCollectorInput(null);
|
||||
setCollectorError("");
|
||||
void importCollectorText(draft);
|
||||
};
|
||||
|
||||
const toggleCollectorRowSelection = (rowId: string): void => {
|
||||
setSelectedCollectorRowIds((prev) => {
|
||||
const setCollectorLinkSelection = (linkId: string, selected: boolean): void => {
|
||||
setSelectedCollectorLinkIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowId)) {
|
||||
next.delete(rowId);
|
||||
} else {
|
||||
next.add(rowId);
|
||||
}
|
||||
if (selected) next.add(linkId);
|
||||
else next.delete(linkId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeSelectedCollectorRows = (): void => {
|
||||
if (selectedCollectorRowIds.size === 0) {
|
||||
return;
|
||||
const setCollectorPackageSelection = (packageId: string, selected: boolean): void => {
|
||||
const pkg = collectorPackagesRef.current.find((entry) => entry.id === packageId);
|
||||
if (pkg) {
|
||||
setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, pkg, selected));
|
||||
}
|
||||
const activeId = activeCollectorTabRef.current;
|
||||
const indexes = new Set<number>();
|
||||
for (const rowId of selectedCollectorRowIds) {
|
||||
const separator = rowId.lastIndexOf(":");
|
||||
if (separator <= 0 || rowId.slice(0, separator) !== activeId) {
|
||||
continue;
|
||||
}
|
||||
const index = Number(rowId.slice(separator + 1));
|
||||
if (Number.isInteger(index) && index >= 0) {
|
||||
indexes.add(index);
|
||||
}
|
||||
}
|
||||
if (indexes.size === 0) {
|
||||
};
|
||||
|
||||
const toggleCollectorPackageCollapse = (packageId: string): void => {
|
||||
setCollapsedCollectorPackageIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(packageId)) next.delete(packageId);
|
||||
else next.add(packageId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllCollectorPackages = (): void => {
|
||||
const packageIds = collectorPackagesRef.current.map((pkg) => pkg.id);
|
||||
setCollapsedCollectorPackageIds((current) => packageIds.some((id) => !current.has(id))
|
||||
? new Set(packageIds)
|
||||
: new Set());
|
||||
};
|
||||
|
||||
const removeSelectedCollectorLinks = (): void => {
|
||||
if (selectedCollectorLinkIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const removedIds = new Set(selectedCollectorLinkIds);
|
||||
void askConfirmPrompt({
|
||||
title: "Ausgewählte Links löschen",
|
||||
message: "Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.",
|
||||
@@ -4037,10 +3992,12 @@ export function App(): ReactElement {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setCollectorTabs((prev) => prev.map((entry) => entry.id === activeId
|
||||
? { ...entry, text: entry.text.split(/\r?\n/).filter((_line, index) => !indexes.has(index)).join("\n") }
|
||||
: entry));
|
||||
setSelectedCollectorRowIds(new Set());
|
||||
setCollectorPackages((current) => {
|
||||
const next = removeCollectorLinks(current, removedIds);
|
||||
collectorPackagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setSelectedCollectorLinkIds(new Set());
|
||||
setCollectorError("");
|
||||
});
|
||||
};
|
||||
@@ -4509,6 +4466,7 @@ export function App(): ReactElement {
|
||||
if (selectionScope) {
|
||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
||||
if (selectionScope === "downloads") setSelectedIds(new Set());
|
||||
else if (selectionScope === "collector") setSelectedCollectorLinkIds(new Set());
|
||||
else if (selectionScope === "history") setSelectedHistoryIds(new Set());
|
||||
else if (selectedAccountRowKeys.size > 0) {
|
||||
setSelectedAccountRowKeys(new Set());
|
||||
@@ -5281,22 +5239,20 @@ export function App(): ReactElement {
|
||||
}
|
||||
};
|
||||
const collectorActions: CollectorViewActions = {
|
||||
onTabSelect: (tabId) => {
|
||||
activeCollectorTabRef.current = tabId;
|
||||
setActiveCollectorTab(tabId);
|
||||
setSelectedCollectorRowIds(new Set());
|
||||
setCollectorError("");
|
||||
},
|
||||
onTabAdd: addCollectorTab,
|
||||
onTabRemove: removeCollectorTab,
|
||||
onFilterChange: setCollectorFilter,
|
||||
onOpenInput: openCollectorInput,
|
||||
onImportDlc: () => { void onImportDlc(); },
|
||||
onImportFile: () => { void onImportQueue(); },
|
||||
onExportQueue: () => { void onExportQueue(); },
|
||||
onSubmit: () => { void onAddLinks(); },
|
||||
onSubmitSelected: () => {
|
||||
void submitCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, selectedCollectorLinkIds));
|
||||
},
|
||||
onSubmitAll: () => { void submitCollectorPackages(collectorPackagesRef.current); },
|
||||
onQueryChange: setCollectorQuery,
|
||||
onSelectionChange: toggleCollectorRowSelection,
|
||||
onRemoveSelected: removeSelectedCollectorRows
|
||||
onLinkSelectionChange: setCollectorLinkSelection,
|
||||
onPackageSelectionChange: setCollectorPackageSelection,
|
||||
onPackageCollapseChange: toggleCollectorPackageCollapse,
|
||||
onToggleAllPackages: toggleAllCollectorPackages,
|
||||
onRemoveSelected: removeSelectedCollectorLinks
|
||||
};
|
||||
|
||||
const settingsFormModel = useMemo<SettingsFormViewModel>(() => buildSettingsFormViewModel({
|
||||
@@ -6266,9 +6222,9 @@ export function App(): ReactElement {
|
||||
<DownloadsSidebarStatus model={downloadsViewModel} />
|
||||
) : tab === "collector" ? (
|
||||
<>
|
||||
<span>Sammlungen: {collectorViewModel.tabs.length}</span>
|
||||
<span>Links: {collectorViewModel.tabs.reduce((sum, entry) => sum + entry.linkCount, 0)}</span>
|
||||
<span>Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"}</span>
|
||||
<span>Pakete: {collectorPackages.length}</span>
|
||||
<span>Links: {collectorViewModel.totalCount}</span>
|
||||
<span>Ausgewählt: {collectorViewModel.selectedCount}</span>
|
||||
</>
|
||||
) : tab === "history" ? (
|
||||
<>
|
||||
@@ -6908,7 +6864,6 @@ export function App(): ReactElement {
|
||||
onClose={() => setCollectorInput(null)}
|
||||
onCommit={commitCollectorInput}
|
||||
open
|
||||
tabName={collectorInput.tabName}
|
||||
value={collectorInput.draft}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type CollectorDlcDropMode = "downloads" | "collector";
|
||||
|
||||
export function resolveDroppedDlcPaths(
|
||||
files: ReadonlyArray<File>,
|
||||
getPathForFile: (file: File) => string
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.name.toLowerCase().endsWith(".dlc")) continue;
|
||||
try {
|
||||
const filePath = String(getPathForFile(file) || "").trim();
|
||||
if (filePath) paths.push(filePath);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export async function routeDroppedDlcFiles<TDownload, TCollector>(
|
||||
files: ReadonlyArray<File>,
|
||||
mode: CollectorDlcDropMode,
|
||||
getPathForFile: (file: File) => string,
|
||||
dependencies: {
|
||||
addContainers: (filePaths: string[]) => Promise<TDownload>;
|
||||
inspectContainers: (filePaths: string[], addedAt: number) => Promise<TCollector>;
|
||||
},
|
||||
addedAt = Date.now()
|
||||
): Promise<
|
||||
| { kind: "empty" }
|
||||
| { kind: "downloads"; result: TDownload }
|
||||
| { kind: "collector"; result: TCollector }
|
||||
> {
|
||||
const filePaths = resolveDroppedDlcPaths(files, getPathForFile);
|
||||
if (filePaths.length === 0) return { kind: "empty" };
|
||||
if (mode === "downloads") {
|
||||
return { kind: "downloads", result: await dependencies.addContainers(filePaths) };
|
||||
}
|
||||
return { kind: "collector", result: await dependencies.inspectContainers(filePaths, addedAt) };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CollectorPackage } from "../shared/collector";
|
||||
|
||||
export type CollectorEnrichmentGenerationSnapshot = Map<string, number>;
|
||||
|
||||
function collectorUrlKey(url: string): string {
|
||||
return url.trim();
|
||||
}
|
||||
|
||||
export function beginCollectorEnrichment(
|
||||
packages: CollectorPackage[],
|
||||
current: Map<string, number>
|
||||
): CollectorEnrichmentGenerationSnapshot {
|
||||
const snapshot = new Map<string, number>();
|
||||
for (const link of packages.flatMap((pkg) => pkg.links)) {
|
||||
const url = collectorUrlKey(link.url);
|
||||
const generation = (current.get(url) ?? 0) + 1;
|
||||
current.set(url, generation);
|
||||
snapshot.set(url, generation);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function filterCurrentCollectorEnrichment(
|
||||
packages: CollectorPackage[],
|
||||
requested: CollectorEnrichmentGenerationSnapshot,
|
||||
current: ReadonlyMap<string, number>
|
||||
): CollectorPackage[] {
|
||||
return packages.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => {
|
||||
const url = collectorUrlKey(link.url);
|
||||
return requested.get(url) === current.get(url);
|
||||
});
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
});
|
||||
}
|
||||
@@ -18,11 +18,11 @@ export function resolveEscapeSelectionScope(
|
||||
settingsSection: string,
|
||||
tagName: string,
|
||||
inputType = ""
|
||||
): "downloads" | "history" | "accounts" | null {
|
||||
): "downloads" | "collector" | "history" | "accounts" | null {
|
||||
if (!shouldClearDownloadSelectionOnEscape(tagName, inputType)) {
|
||||
return null;
|
||||
}
|
||||
if (view === "downloads" || view === "history") {
|
||||
if (view === "downloads" || view === "collector" || view === "history") {
|
||||
return view;
|
||||
}
|
||||
return view === "settings" && settingsSection === "accounts" ? "accounts" : null;
|
||||
|
||||
@@ -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