feat(collector): rebuild link preview as package workspace
Inspect pasted links, text files and DLC containers before queue insertion and resolve supported hoster metadata without starting downloads. Group multipart files into expandable packages with size, status, availability, timestamps, filtering and stable selection. Add selected or complete transfer to Downloads while retaining entries on failed or partial handoff. Keep collector toolbar spacing consistent and show the full 1Fichier identity with its provider icon in Downloads.
This commit is contained in:
+139
-305
@@ -2,6 +2,8 @@ import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect
|
||||
import { flushSync } from "react-dom";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import type { RealDebridLoginRequest } from "../shared/preload-api";
|
||||
import { serializeCollectorPackages } from "../shared/collector";
|
||||
import type { CollectorInspectionResult, CollectorPackage } from "../shared/collector";
|
||||
import type {
|
||||
AccountCreateCommand,
|
||||
AllDebridHostInfo,
|
||||
@@ -61,8 +63,12 @@ import { Dialog } from "./ui/Dialog";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { Toast } from "./ui/Toast";
|
||||
import {
|
||||
buildCollectorViewModel,
|
||||
type CollectorSourceTab
|
||||
buildCollectorTransferPackages,
|
||||
buildCollectorWorkspaceViewModel,
|
||||
mergeCollectorPackages,
|
||||
removeCollectorLinks,
|
||||
selectCollectorPackageLinks,
|
||||
type CollectorWorkspaceFilter
|
||||
} from "./views/collector/collector-model";
|
||||
import {
|
||||
CollectorContent,
|
||||
@@ -138,64 +144,9 @@ import {
|
||||
|
||||
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;
|
||||
@@ -1375,9 +1326,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)}`;
|
||||
}
|
||||
|
||||
@@ -1556,16 +1505,18 @@ 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 [collectorBusy, setCollectorBusy] = useState(false);
|
||||
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 inspectCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
|
||||
const collectorInspectionQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const collectorInspectionPendingRef = useRef(0);
|
||||
const activeTabRef = useRef<Tab>(tab);
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
@@ -1680,14 +1631,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]);
|
||||
collectorBusy || actionBusy,
|
||||
[...selectedCollectorLinkIds],
|
||||
[...collapsedCollectorPackageIds],
|
||||
collectorError,
|
||||
snapshot.settings.animatePackageDisclosure
|
||||
), [actionBusy, collapsedCollectorPackageIds, collectorBusy, collectorError, collectorFilter, collectorPackages, collectorQuery, selectedCollectorLinkIds, snapshot.settings.animatePackageDisclosure]);
|
||||
|
||||
const historyViewModel = useMemo(() => buildHistoryViewModel(
|
||||
historyEntries,
|
||||
@@ -1706,13 +1659,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;
|
||||
@@ -2046,15 +1993,10 @@ export function App(): ReactElement {
|
||||
}
|
||||
}, 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 inspectCollectorTextRef.current(links.join("\n"));
|
||||
});
|
||||
unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
@@ -3463,49 +3405,74 @@ export function App(): ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const onAddLinks = async (): Promise<void> => {
|
||||
const enqueueCollectorInspection = (inspect: () => Promise<CollectorInspectionResult>): Promise<void> => {
|
||||
collectorInspectionPendingRef.current += 1;
|
||||
setCollectorBusy(true);
|
||||
setCollectorError("");
|
||||
const run = collectorInspectionQueueRef.current.catch(() => undefined).then(async () => {
|
||||
const result = await inspect();
|
||||
const incomingCount = result.packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
||||
setCollectorPackages((current) => mergeCollectorPackages(current, result.packages).packages);
|
||||
setCollectorFilter("all");
|
||||
setTab("collector");
|
||||
const ignored = result.invalidCount + result.duplicateCount;
|
||||
showToast(`${incomingCount} Link(s) analysiert${ignored > 0 ? ` · ${ignored} ignoriert` : ""}`, 2600);
|
||||
}).catch((error) => {
|
||||
setCollectorError(`Analyse fehlgeschlagen: ${String(error)}`);
|
||||
showToast(`Analyse fehlgeschlagen: ${String(error)}`, 3000);
|
||||
}).finally(() => {
|
||||
collectorInspectionPendingRef.current = Math.max(0, collectorInspectionPendingRef.current - 1);
|
||||
if (collectorInspectionPendingRef.current === 0) setCollectorBusy(false);
|
||||
});
|
||||
collectorInspectionQueueRef.current = run;
|
||||
return run;
|
||||
};
|
||||
|
||||
const inspectCollectorRawText = (rawText: string): Promise<void> => {
|
||||
if (!rawText.trim()) {
|
||||
showToast("Keine Links gefunden", 2200);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return enqueueCollectorInspection(() => window.rd.inspectCollectorText({ rawText, addedAt: Date.now() }));
|
||||
};
|
||||
inspectCollectorTextRef.current = inspectCollectorRawText;
|
||||
|
||||
const transferCollectorPackages = 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 transferredIds = new Set(transferable.flatMap((pkg) => pkg.links.map((link) => link.id)));
|
||||
if (transferredIds.size === 0) {
|
||||
showToast("Keine übertragbaren Links ausgewählt", 2400);
|
||||
return;
|
||||
}
|
||||
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");
|
||||
}
|
||||
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
|
||||
const result = await window.rd.addLinks({ rawText: serializeCollectorPackages(transferable) });
|
||||
if (result.addedLinks !== transferredIds.size) {
|
||||
throw new Error(`Nur ${result.addedLinks} von ${transferredIds.size} Links wurden übernommen`);
|
||||
}
|
||||
setCollectorPackages((current) => removeCollectorLinks(current, transferredIds));
|
||||
setSelectedCollectorLinkIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of transferredIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) an Downloads übergeben`);
|
||||
if (snapshotRef.current.settings.collapseNewPackages) await collapseNewPackages(existingIds);
|
||||
}, (error) => {
|
||||
setCollectorError(`Fehler beim Hinzufügen: ${String(error)}`);
|
||||
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
|
||||
setCollectorError(`Übergabe fehlgeschlagen: ${String(error)}`);
|
||||
showToast(`Übergabe fehlgeschlagen: ${String(error)}`, 3000);
|
||||
});
|
||||
};
|
||||
|
||||
const onImportDlc = async (): Promise<void> => {
|
||||
setCollectorError("");
|
||||
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);
|
||||
}
|
||||
}, (error) => {
|
||||
setCollectorError(`Fehler beim DLC-Import: ${String(error)}`);
|
||||
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
|
||||
});
|
||||
};
|
||||
const files = await window.rd.pickContainers();
|
||||
if (files.length === 0) return;
|
||||
await enqueueCollectorInspection(() => window.rd.inspectCollectorContainers(files, Date.now()));
|
||||
};
|
||||
|
||||
const onExportPackageSelection = async (packageIds: string[]): Promise<void> => {
|
||||
closeMenus();
|
||||
@@ -3544,55 +3511,15 @@ export function App(): ReactElement {
|
||||
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") || "";
|
||||
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");
|
||||
showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000);
|
||||
}
|
||||
}, (error) => {
|
||||
setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`);
|
||||
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
||||
});
|
||||
await enqueueCollectorInspection(() => window.rd.inspectCollectorContainers(dlc, Date.now()));
|
||||
} 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)}`);
|
||||
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");
|
||||
}
|
||||
const importedText = (await Promise.all(importFiles.map((file) => file.text()))).join("\n");
|
||||
await inspectCollectorRawText(importedText);
|
||||
} else if (droppedText.trim()) {
|
||||
await inspectCollectorRawText(droppedText);
|
||||
}
|
||||
};
|
||||
|
||||
const onExportQueue = async (): Promise<void> => {
|
||||
@@ -3634,28 +3561,13 @@ 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)}`);
|
||||
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
|
||||
});
|
||||
};
|
||||
if (!file) {
|
||||
releasePickerBusy();
|
||||
return;
|
||||
}
|
||||
releasePickerBusy();
|
||||
await inspectCollectorRawText(await file.text());
|
||||
};
|
||||
|
||||
clearImportQueueFocusListener();
|
||||
importQueueFocusHandlerRef.current = onWindowFocus;
|
||||
@@ -3759,112 +3671,35 @@ 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());
|
||||
if (!collectorInput) return;
|
||||
const rawText = collectorInput.draft;
|
||||
setCollectorInput(null);
|
||||
setCollectorError("");
|
||||
void inspectCollectorRawText(rawText);
|
||||
};
|
||||
|
||||
const toggleCollectorRowSelection = (rowId: string): void => {
|
||||
setSelectedCollectorRowIds((prev) => {
|
||||
const toggleCollectorLinkSelection = (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 toggleCollectorPackageSelection = (packageId: string, selected: boolean): void => {
|
||||
const pkg = collectorPackagesRef.current.find((entry) => entry.id === packageId);
|
||||
if (!pkg) return;
|
||||
setSelectedCollectorLinkIds((current) => selectCollectorPackageLinks(current, pkg, selected));
|
||||
};
|
||||
|
||||
const removeSelectedCollectorRows = (): void => {
|
||||
if (selectedCollectorRowIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if (selectedCollectorLinkIds.size === 0) return;
|
||||
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.",
|
||||
@@ -3874,10 +3709,8 @@ 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) => removeCollectorLinks(current, selectedCollectorLinkIds));
|
||||
setSelectedCollectorLinkIds(new Set());
|
||||
setCollectorError("");
|
||||
});
|
||||
};
|
||||
@@ -4346,6 +4179,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());
|
||||
@@ -4368,7 +4202,7 @@ export function App(): ReactElement {
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); };
|
||||
}, [requestDeleteSelection, selectedAccountRowKeys, selectedIds, settingsSubTab]);
|
||||
}, [requestDeleteSelection, selectedAccountRowKeys, selectedCollectorLinkIds, selectedIds, settingsSubTab]);
|
||||
|
||||
const onExportBackup = async (): Promise<void> => {
|
||||
closeMenus();
|
||||
@@ -5067,21 +4901,21 @@ 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 transferCollectorPackages(buildCollectorTransferPackages(collectorPackagesRef.current, selectedCollectorLinkIds)); },
|
||||
onSubmitAll: () => { void transferCollectorPackages(collectorPackagesRef.current); },
|
||||
onQueryChange: setCollectorQuery,
|
||||
onSelectionChange: toggleCollectorRowSelection,
|
||||
onLinkSelectionChange: toggleCollectorLinkSelection,
|
||||
onPackageSelectionChange: toggleCollectorPackageSelection,
|
||||
onPackageCollapseChange: (packageId) => setCollapsedCollectorPackageIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(packageId)) next.delete(packageId);
|
||||
else next.add(packageId);
|
||||
return next;
|
||||
}),
|
||||
onRemoveSelected: removeSelectedCollectorRows
|
||||
};
|
||||
|
||||
@@ -6044,8 +5878,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>Pakete: {collectorPackages.length}</span>
|
||||
<span>Links: {collectorViewModel.totalCount}</span>
|
||||
<span>Ausgewählt: {collectorViewModel.selectedCount}</span>
|
||||
<span>Zwischenablage: {snapshot.clipboardActive ? "An" : "Aus"}</span>
|
||||
</>
|
||||
) : tab === "history" ? (
|
||||
@@ -6683,7 +6518,6 @@ export function App(): ReactElement {
|
||||
onClose={() => setCollectorInput(null)}
|
||||
onCommit={commitCollectorInput}
|
||||
open
|
||||
tabName={collectorInput.tabName}
|
||||
value={collectorInput.draft}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function formatHosterLabel(hoster: string): { compact: string; title: str
|
||||
const normalized = hoster.trim().toLowerCase();
|
||||
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
|
||||
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
|
||||
if (normalized === "1fichier") return { compact: "1F", title: "1Fichier" };
|
||||
if (normalized === "1fichier") return { compact: "1Fichier", title: "1Fichier", iconSrc: hosterIconSources.onefichier };
|
||||
return { compact: hoster, title: hoster };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const hosterIconSources: Readonly<Record<string, string>> = {
|
||||
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
|
||||
onefichier: "./provider-icons/onefichier.png",
|
||||
ddownload: "./provider-icons/ddownload.ico"
|
||||
};
|
||||
|
||||
@@ -70,6 +70,10 @@ const pairs = [
|
||||
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"],
|
||||
["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"],
|
||||
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
|
||||
["Linksammler-Filter", "Link collector filters"], ["Alle Links", "All links"], ["Downloads übergeben", "Send to downloads"], ["Gesammelte Downloadpakete", "Collected download packages"], ["Hinzugefügt", "Added"],
|
||||
["Name, URL oder Hoster", "Name, URL or hoster"], ["Links werden analysiert", "Analyzing links"], ["Dateinamen, Größen und Verfügbarkeit werden geprüft.", "Checking filenames, sizes, and availability."],
|
||||
["Bereits gesammelte Pakete bleiben unverändert.", "Already collected packages remain unchanged."], ["Füge Links hinzu, um Pakete vor dem Download zu prüfen.", "Add links to inspect packages before downloading."],
|
||||
["Links werden geprüft und automatisch zu Downloadpaketen gruppiert.", "Links are inspected and automatically grouped into download packages."], ["Analysieren", "Analyze"], ["Eine URL pro Zeile", "One URL per line"],
|
||||
["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Ausgewählte Links löschen", "Delete selected links"], ["Links löschen", "Delete links"], ["Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", "The selected links will be removed from the collection. This action cannot be undone."], ["Gesammelte Links", "Collected links"],
|
||||
["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."],
|
||||
["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"],
|
||||
@@ -319,6 +323,14 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
||||
if (capture) return `Capture links locally for ${capture[1]}.`;
|
||||
const collectorSelection = value.match(/^(.+) aus (.+), Zeile (\d+) auswählen$/);
|
||||
if (collectorSelection) return `Select ${collectorSelection[1]} from ${collectorSelection[2]}, line ${collectorSelection[3]}`;
|
||||
const collectorTransfer = value.match(/^(Auswahl|Alle) übergeben \((\d+)\)$/);
|
||||
if (collectorTransfer) return `${collectorTransfer[1] === "Auswahl" ? "Send selection" : "Send all"} (${collectorTransfer[2]})`;
|
||||
const collectorPackageSelect = value.match(/^Paket (.+) auswählen$/);
|
||||
if (collectorPackageSelect) return `Select package ${collectorPackageSelect[1]}`;
|
||||
const collectorFiles = value.match(/^(\d+) Dateien$/);
|
||||
if (collectorFiles) return `${collectorFiles[1]} files`;
|
||||
const collectorChecked = value.match(/^(\d+)\/(\d+) geprüft$/);
|
||||
if (collectorChecked) return `${collectorChecked[1]}/${collectorChecked[2]} checked`;
|
||||
const copy = value.match(/^([\s\S]+?)\s+Klicken zum Kopieren$/);
|
||||
if (copy) return `Click to copy ${copy[1]}`;
|
||||
const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
|
||||
@@ -530,6 +542,14 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
||||
if (pageStatus) return `Seite ${pageStatus[1]} von ${pageStatus[2]}`;
|
||||
const collectorSelection = value.match(/^Select (.+) from (.+), line (\d+)$/);
|
||||
if (collectorSelection) return `${collectorSelection[1]} aus ${collectorSelection[2]}, Zeile ${collectorSelection[3]} auswählen`;
|
||||
const collectorTransfer = value.match(/^Send (selection|all) \((\d+)\)$/);
|
||||
if (collectorTransfer) return `${collectorTransfer[1] === "selection" ? "Auswahl" : "Alle"} übergeben (${collectorTransfer[2]})`;
|
||||
const collectorPackageSelect = value.match(/^Select package (.+)$/);
|
||||
if (collectorPackageSelect) return `Paket ${collectorPackageSelect[1]} auswählen`;
|
||||
const collectorFiles = value.match(/^(\d+) files$/);
|
||||
if (collectorFiles) return `${collectorFiles[1]} Dateien`;
|
||||
const collectorChecked = value.match(/^(\d+)\/(\d+) checked$/);
|
||||
if (collectorChecked) return `${collectorChecked[1]}/${collectorChecked[2]} geprüft`;
|
||||
const moveColumnDirection = value.match(/^Move (.+) (left|right)$/);
|
||||
if (moveColumnDirection) return `${enToDe.get(moveColumnDirection[1]) ?? moveColumnDirection[1]} nach ${moveColumnDirection[2] === "left" ? "links" : "rechts"} verschieben`;
|
||||
const moveColumn = value.match(/^Move (.+)$/);
|
||||
|
||||
@@ -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 === "history" || view === "collector") {
|
||||
return view;
|
||||
}
|
||||
return view === "settings" && settingsSection === "accounts" ? "accounts" : null;
|
||||
|
||||
@@ -1,210 +1,149 @@
|
||||
import type { ChangeEvent, ReactElement } from "react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import type { ChangeEvent, ReactElement } from "react";
|
||||
import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import type { CollectorViewModel } from "./collector-model";
|
||||
import "./collector.css";
|
||||
|
||||
export interface CollectorViewActions {
|
||||
onTabSelect: (tabId: string) => void;
|
||||
onTabAdd: () => void;
|
||||
onTabRemove: (tabId: string) => void;
|
||||
onOpenInput: () => void;
|
||||
onImportDlc: () => void;
|
||||
onImportFile: () => void;
|
||||
onExportQueue: () => void;
|
||||
onSubmit: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelectionChange: (rowId: string) => void;
|
||||
onRemoveSelected: () => void;
|
||||
}
|
||||
|
||||
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
|
||||
|
||||
export interface CollectorViewProps {
|
||||
model: CollectorViewModel;
|
||||
actions: CollectorViewActions;
|
||||
region?: CollectorViewRegion;
|
||||
}
|
||||
|
||||
export interface CollectorInputDialogProps {
|
||||
open: boolean;
|
||||
tabName: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format";
|
||||
import type { CollectorWorkspaceFilter, CollectorWorkspacePackageRow, CollectorWorkspaceViewModel } from "./collector-model";
|
||||
import "./collector.css";
|
||||
|
||||
export interface CollectorViewActions {
|
||||
onFilterChange: (filter: CollectorWorkspaceFilter) => void;
|
||||
onOpenInput: () => void;
|
||||
onImportDlc: () => void;
|
||||
onImportFile: () => void;
|
||||
onSubmitSelected: () => void;
|
||||
onSubmitAll: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onLinkSelectionChange: (linkId: string, selected: boolean) => void;
|
||||
onPackageSelectionChange: (packageId: string, selected: boolean) => void;
|
||||
onPackageCollapseChange: (packageId: string) => void;
|
||||
onRemoveSelected: () => void;
|
||||
}
|
||||
|
||||
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
|
||||
|
||||
export interface CollectorViewProps {
|
||||
model: CollectorWorkspaceViewModel;
|
||||
actions: CollectorViewActions;
|
||||
region?: CollectorViewRegion;
|
||||
}
|
||||
|
||||
export interface CollectorInputDialogProps {
|
||||
open: boolean;
|
||||
tabName?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
function packageStatus(row: CollectorWorkspacePackageRow): string {
|
||||
const ready = row.allLinks.filter((link) => link.status === "ready").length;
|
||||
if (row.offlineCount === row.totalCount) return "Offline";
|
||||
if (ready === row.totalCount) return "Bereit";
|
||||
if (ready > 0 || row.onlineCount > 0) return `${ready}/${row.totalCount} geprüft`;
|
||||
return "Ungeprüft";
|
||||
}
|
||||
|
||||
function packageAvailability(row: CollectorWorkspacePackageRow): string {
|
||||
if (row.onlineCount === row.totalCount) return `${row.onlineCount}/${row.totalCount} online`;
|
||||
if (row.offlineCount === row.totalCount) return "Offline";
|
||||
if (row.onlineCount > 0) return `${row.onlineCount}/${row.totalCount} online`;
|
||||
return "Ungeprüft";
|
||||
}
|
||||
|
||||
function packageSize(row: CollectorWorkspacePackageRow): string {
|
||||
if (row.totalBytes <= 0) return "Unbekannt";
|
||||
return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`;
|
||||
}
|
||||
|
||||
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
return (
|
||||
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
|
||||
<div className="collector-sidebar-heading">
|
||||
<strong>Sammlungen</strong>
|
||||
<span>{model.tabs.length}</span>
|
||||
</div>
|
||||
<SlidingSelection activeKey={model.activeTabId} axis="vertical" className="collector-sidebar-list">
|
||||
{model.tabs.map((tab) => (
|
||||
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} data-sliding-selection-active={tab.id === model.activeTabId} data-sliding-selection-item="true" key={tab.id}>
|
||||
<button
|
||||
aria-current={tab.id === model.activeTabId ? "page" : undefined}
|
||||
className="collector-sidebar-select"
|
||||
onClick={() => actions.onTabSelect(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{tab.name}</span>
|
||||
<span className="collector-sidebar-count">{tab.linkCount}</span>
|
||||
</button>
|
||||
{model.tabs.length > 1 ? (
|
||||
<button
|
||||
aria-label={`${tab.name} entfernen`}
|
||||
className="collector-sidebar-remove"
|
||||
onClick={() => actions.onTabRemove(tab.id)}
|
||||
type="button"
|
||||
>×</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div aria-label="Linksammler-Filter" className="collector-sidebar" data-visual-region="collector-sidebar">
|
||||
<div className="collector-sidebar-heading"><strong>Status</strong><span>{model.totalCount}</span></div>
|
||||
<SlidingSelection activeKey={model.filter} axis="vertical" className="collector-sidebar-list">
|
||||
{model.filters.map((filter) => (
|
||||
<button aria-current={filter.id === model.filter ? "page" : undefined} className={`collector-sidebar-filter${filter.id === model.filter ? " is-active" : ""}`} data-sliding-selection-active={filter.id === model.filter} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button">
|
||||
<span>{filter.label}</span><span>{filter.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</SlidingSelection>
|
||||
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
const activeTab = model.tabs.find((tab) => tab.id === model.activeTabId) ?? model.tabs[0];
|
||||
return (
|
||||
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
||||
<ToolbarGroup label="Links erfassen">
|
||||
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup label="Sammlung verarbeiten">
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
|
||||
<button className="collector-action" disabled={model.busy || !activeTab || activeTab.linkCount === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSearch
|
||||
label="Links durchsuchen"
|
||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||
placeholder="Links durchsuchen"
|
||||
value={model.query}
|
||||
/>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
||||
const selected = new Set(model.selectedIds);
|
||||
return (
|
||||
<section className="collector-content" aria-label="Gesammelte Links">
|
||||
<DataTable className="collector-table" label="Gesammelte Links">
|
||||
<DataTableHeader className="collector-table-header">
|
||||
<div className="collector-table-header-row" role="row">
|
||||
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
|
||||
<span role="columnheader">Sammlung</span>
|
||||
<span role="columnheader">URL oder Rohzeile</span>
|
||||
<span role="columnheader">Zeile</span>
|
||||
<span role="columnheader">Status</span>
|
||||
</div>
|
||||
</DataTableHeader>
|
||||
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
||||
{model.busy ? (
|
||||
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
|
||||
) : model.error ? (
|
||||
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
|
||||
) : model.empty ? (
|
||||
<DataTableEmpty
|
||||
data-visual-region="collector-empty-state"
|
||||
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
|
||||
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
|
||||
/>
|
||||
) : (
|
||||
model.rows.map((row) => (
|
||||
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
|
||||
<span className="collector-column-select" role="cell">
|
||||
<input
|
||||
aria-label={`${row.value} aus ${row.tabName}, Zeile ${row.lineNumber} auswählen`}
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => actions.onSelectionChange(row.id)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</span>
|
||||
<span className="collector-row-source" role="cell">{row.tabName}</span>
|
||||
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
|
||||
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
|
||||
<span className="collector-row-status" role="cell">Lokal</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
||||
if (region === "sidebar") {
|
||||
return <CollectorSidebar actions={actions} model={model} />;
|
||||
}
|
||||
if (region === "toolbar") {
|
||||
return <CollectorToolbar actions={actions} model={model} />;
|
||||
}
|
||||
if (region === "content") {
|
||||
return <CollectorContent actions={actions} model={model} />;
|
||||
}
|
||||
return (
|
||||
<div className="collector-view">
|
||||
<CollectorSidebar actions={actions} model={model} />
|
||||
<div className="collector-view-main">
|
||||
<CollectorToolbar actions={actions} model={model} />
|
||||
<CollectorContent actions={actions} model={model} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorInputDialog({
|
||||
open,
|
||||
tabName,
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
onCommit
|
||||
}: CollectorInputDialogProps): ReactElement | null {
|
||||
return (
|
||||
<Dialog
|
||||
actions={(
|
||||
<>
|
||||
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
|
||||
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
|
||||
</>
|
||||
)}
|
||||
description={`Links für ${tabName} lokal erfassen.`}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
size="wide"
|
||||
title="Links hinzufügen"
|
||||
>
|
||||
<label className="collector-input-label">
|
||||
<span>Links</span>
|
||||
<textarea
|
||||
aria-label="Links"
|
||||
autoFocus
|
||||
className="collector-input"
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
|
||||
placeholder="Eine URL oder Rohzeile pro Zeile"
|
||||
rows={12}
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
||||
<ToolbarGroup label="Links erfassen">
|
||||
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup label="Downloads übergeben">
|
||||
<button className="collector-action" disabled={model.busy || model.selectedCount === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${model.selectedCount})`}</button>
|
||||
<button className="collector-action" disabled={model.busy || model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
|
||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSearch label="Links durchsuchen" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} />
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectorPackageGroup({ row, model, actions }: { row: CollectorWorkspacePackageRow; model: CollectorWorkspaceViewModel; actions: CollectorViewActions }): ReactElement {
|
||||
const selected = new Set(model.selectedIds);
|
||||
const allSelected = row.selectedCount === row.totalCount;
|
||||
const partiallySelected = row.selectedCount > 0 && !allSelected;
|
||||
const animateItems = model.animationsEnabled && row.allLinks.length <= 64;
|
||||
const renderItems = !row.collapsed || animateItems;
|
||||
return (
|
||||
<div className={`collector-package-group${row.collapsed ? " is-collapsed" : ""}${model.animationsEnabled ? " is-motion-enabled" : ""}`} role="rowgroup">
|
||||
<div className={`collector-package-row${row.selectedCount > 0 ? " is-selected" : ""}`} role="row">
|
||||
<span className="collector-column-select" role="cell"><input aria-checked={partiallySelected ? "mixed" : allSelected} aria-label={`Paket ${row.name} auswählen`} checked={allSelected} onChange={(event) => actions.onPackageSelectionChange(row.id, event.target.checked)} ref={(node) => { if (node) node.indeterminate = partiallySelected; }} type="checkbox" /></span>
|
||||
<span className="collector-name-cell" role="cell"><button aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${row.name} ausklappen` : `${row.name} einklappen`} className="collector-collapse-button" onClick={() => actions.onPackageCollapseChange(row.id)} type="button">{row.collapsed ? "+" : "−"}</button><strong title={row.name}>{row.name}</strong><small>{row.totalCount} Dateien</small></span>
|
||||
<span className="collector-size-cell" role="cell">{packageSize(row)}</span>
|
||||
<span className="collector-hoster-cell" role="cell">{row.hosters.map(formatHosterLabel).map((hoster) => <span key={hoster.title} title={hoster.title}>{hoster.compact}</span>)}</span>
|
||||
<span className="collector-status-cell" role="cell">{packageStatus(row)}</span>
|
||||
<span className={`collector-availability-cell is-${row.offlineCount === row.totalCount ? "offline" : row.onlineCount === row.totalCount ? "online" : "unknown"}`} role="cell">{packageAvailability(row)}</span>
|
||||
<span className="collector-added-cell" role="cell">{formatDateTime(row.addedAt)}</span>
|
||||
</div>
|
||||
{renderItems ? <div className={`collector-package-items-frame${row.collapsed ? " is-collapsed" : ""}${animateItems ? " is-animated" : ""}`}><div className="collector-package-items">{row.links.map((link) => {
|
||||
const hoster = formatHosterLabel(link.hoster);
|
||||
return (
|
||||
<div className={`collector-file-row${selected.has(link.id) ? " is-selected" : ""}`} key={link.id} role="row">
|
||||
<span className="collector-column-select" role="cell"><input aria-label={`${link.fileName} auswählen`} checked={selected.has(link.id)} onChange={(event) => actions.onLinkSelectionChange(link.id, event.target.checked)} type="checkbox" /></span>
|
||||
<span className="collector-name-cell is-file" role="cell" title={link.url}><span className={`collector-link-state is-${link.availability}`} />{link.fileName}</span>
|
||||
<span className="collector-size-cell" role="cell">{link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)}</span>
|
||||
<span className="collector-hoster-cell" role="cell"><span title={hoster.title}>{hoster.compact}</span></span>
|
||||
<span className="collector-status-cell" role="cell">{link.status === "ready" ? "Bereit" : link.status === "offline" ? "Offline" : "Ungeprüft"}</span>
|
||||
<span className={`collector-availability-cell is-${link.availability}`} role="cell">{link.availability === "online" ? "Online" : link.availability === "offline" ? "Offline" : "Ungeprüft"}</span>
|
||||
<span className="collector-added-cell" role="cell">{formatDateTime(link.addedAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})}</div></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
|
||||
return (
|
||||
<section className="collector-content" aria-label="Gesammelte Downloadpakete"><DataTable className="collector-table" label="Gesammelte Downloadpakete">
|
||||
<DataTableHeader className="collector-table-header"><div className="collector-table-header-row" role="row"><span aria-label="Auswahl" className="collector-column-select" role="columnheader" /><span role="columnheader">Name</span><span role="columnheader">Größe</span><span role="columnheader">Hoster</span><span role="columnheader">Status</span><span role="columnheader">Verfügbarkeit</span><span role="columnheader">Hinzugefügt</span></div></DataTableHeader>
|
||||
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
|
||||
{model.busy ? <DataTableEmpty title="Links werden analysiert" description="Dateinamen, Größen und Verfügbarkeit werden geprüft." /> : model.error ? <DataTableEmpty className="collector-table-error" title={model.error} description="Bereits gesammelte Pakete bleiben unverändert." /> : model.empty ? <DataTableEmpty data-visual-region="collector-empty-state" description={model.query || model.filter !== "all" ? "Passe Suche oder Statusfilter an." : "Füge Links hinzu, um Pakete vor dem Download zu prüfen."} title={model.query || model.filter !== "all" ? "Keine passenden Links" : "Noch keine Links"} /> : model.packages.map((row) => <CollectorPackageGroup actions={actions} key={row.id} model={model} row={row} />)}
|
||||
</DataTableBody>
|
||||
</DataTable></section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
|
||||
if (region === "sidebar") return <CollectorSidebar actions={actions} model={model} />;
|
||||
if (region === "toolbar") return <CollectorToolbar actions={actions} model={model} />;
|
||||
if (region === "content") return <CollectorContent actions={actions} model={model} />;
|
||||
return <div className="collector-view"><CollectorSidebar actions={actions} model={model} /><div className="collector-view-main"><CollectorToolbar actions={actions} model={model} /><CollectorContent actions={actions} model={model} /></div></div>;
|
||||
}
|
||||
|
||||
export function CollectorInputDialog({ open, value, onChange, onClose, onCommit }: CollectorInputDialogProps): ReactElement | null {
|
||||
return <Dialog actions={<><button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button><button className="collector-dialog-primary" onClick={onCommit} type="button">Analysieren</button></>} description="Links werden geprüft und automatisch zu Downloadpaketen gruppiert." onClose={onClose} open={open} size="wide" title="Links hinzufügen"><label className="collector-input-label"><span>Links</span><textarea aria-label="Links" autoFocus className="collector-input" onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)} placeholder="Eine URL pro Zeile" rows={12} value={value} /></label></Dialog>;
|
||||
}
|
||||
|
||||
@@ -1,88 +1,168 @@
|
||||
export interface CollectorSourceTab {
|
||||
import type { CollectorLink, CollectorPackage } from "../../../shared/collector";
|
||||
|
||||
export type CollectorWorkspaceFilter = "all" | "online" | "unknown" | "offline";
|
||||
|
||||
export interface CollectorWorkspaceFilterEntry {
|
||||
id: CollectorWorkspaceFilter;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CollectorWorkspacePackageRow {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
links: CollectorLink[];
|
||||
allLinks: CollectorLink[];
|
||||
totalBytes: number;
|
||||
unknownSizeCount: number;
|
||||
onlineCount: number;
|
||||
offlineCount: number;
|
||||
unknownCount: number;
|
||||
totalCount: number;
|
||||
selectedCount: number;
|
||||
collapsed: boolean;
|
||||
addedAt: number;
|
||||
hosters: string[];
|
||||
}
|
||||
|
||||
export interface CollectorTabSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
linkCount: number;
|
||||
}
|
||||
|
||||
export interface CollectorRow {
|
||||
id: string;
|
||||
tabId: string;
|
||||
tabName: string;
|
||||
originalLineIndex: number;
|
||||
lineNumber: number;
|
||||
value: string;
|
||||
linkCount: number;
|
||||
}
|
||||
|
||||
export interface CollectorViewModel {
|
||||
tabs: CollectorTabSummary[];
|
||||
activeTabId: string;
|
||||
rows: CollectorRow[];
|
||||
busy: boolean;
|
||||
export interface CollectorWorkspaceViewModel {
|
||||
packages: CollectorWorkspacePackageRow[];
|
||||
filters: CollectorWorkspaceFilterEntry[];
|
||||
filter: CollectorWorkspaceFilter;
|
||||
query: string;
|
||||
selectedIds: string[];
|
||||
empty: boolean;
|
||||
busy: boolean;
|
||||
error: string;
|
||||
empty: boolean;
|
||||
totalCount: number;
|
||||
selectedCount: number;
|
||||
selectedIds: string[];
|
||||
animationsEnabled: boolean;
|
||||
}
|
||||
|
||||
function nonEmptyLines(tab: CollectorSourceTab): Array<{ originalLineIndex: number; value: string }> {
|
||||
return tab.text
|
||||
.split(/\r?\n/)
|
||||
.map((value, originalLineIndex) => ({ originalLineIndex, value: value.trim() }))
|
||||
.filter((line) => line.value.length > 0);
|
||||
}
|
||||
export function mergeCollectorPackages(
|
||||
current: CollectorPackage[],
|
||||
incoming: CollectorPackage[]
|
||||
): { packages: CollectorPackage[]; addedLinks: number; duplicateLinks: number } {
|
||||
const packages = current.map((pkg) => ({ ...pkg, links: [...pkg.links] }));
|
||||
const packageByName = new Map(packages.map((pkg) => [pkg.name.toLocaleLowerCase("de"), pkg]));
|
||||
const seenUrls = new Set(packages.flatMap((pkg) => pkg.links.map((link) => link.url)));
|
||||
let addedLinks = 0;
|
||||
let duplicateLinks = 0;
|
||||
|
||||
export function buildCollectorRows(
|
||||
tabs: CollectorSourceTab[],
|
||||
activeTabId: string = tabs[0]?.id ?? "",
|
||||
query = ""
|
||||
): CollectorRow[] {
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
|
||||
if (!activeTab) {
|
||||
return [];
|
||||
for (const incomingPackage of incoming) {
|
||||
let target = packageByName.get(incomingPackage.name.toLocaleLowerCase("de"));
|
||||
for (const link of incomingPackage.links) {
|
||||
if (seenUrls.has(link.url)) {
|
||||
duplicateLinks += 1;
|
||||
continue;
|
||||
}
|
||||
if (!target) {
|
||||
target = { ...incomingPackage, links: [], addedAt: incomingPackage.addedAt };
|
||||
packages.push(target);
|
||||
packageByName.set(incomingPackage.name.toLocaleLowerCase("de"), target);
|
||||
}
|
||||
target.links.push(link);
|
||||
target.addedAt = Math.min(target.addedAt, link.addedAt);
|
||||
seenUrls.add(link.url);
|
||||
addedLinks += 1;
|
||||
}
|
||||
}
|
||||
const lines = nonEmptyLines(activeTab);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("de");
|
||||
return lines
|
||||
.filter((line) => !normalizedQuery || line.value.toLocaleLowerCase("de").includes(normalizedQuery))
|
||||
.map((line) => ({
|
||||
id: `${activeTab.id}:${line.originalLineIndex}`,
|
||||
tabId: activeTab.id,
|
||||
tabName: activeTab.name,
|
||||
originalLineIndex: line.originalLineIndex,
|
||||
lineNumber: line.originalLineIndex + 1,
|
||||
value: line.value,
|
||||
linkCount: lines.length
|
||||
}));
|
||||
|
||||
return { packages, addedLinks, duplicateLinks };
|
||||
}
|
||||
|
||||
export function buildCollectorViewModel(
|
||||
tabs: CollectorSourceTab[],
|
||||
activeTabId: string,
|
||||
export function selectCollectorPackageLinks(
|
||||
current: Set<string>,
|
||||
pkg: CollectorPackage,
|
||||
selected: boolean
|
||||
): Set<string> {
|
||||
const next = new Set(current);
|
||||
for (const link of pkg.links) {
|
||||
if (selected) next.add(link.id);
|
||||
else next.delete(link.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildCollectorTransferPackages(packages: CollectorPackage[], selectedIds: Set<string>): CollectorPackage[] {
|
||||
return packages.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => selectedIds.has(link.id));
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function removeCollectorLinks(packages: CollectorPackage[], removedIds: Set<string>): CollectorPackage[] {
|
||||
return packages.flatMap((pkg) => {
|
||||
const links = pkg.links.filter((link) => !removedIds.has(link.id));
|
||||
return links.length > 0 ? [{ ...pkg, links }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function filterCollectorLink(link: CollectorLink, filter: CollectorWorkspaceFilter): boolean {
|
||||
return filter === "all" || link.availability === filter;
|
||||
}
|
||||
|
||||
function collectorLinkMatchesQuery(link: CollectorLink, query: string): boolean {
|
||||
if (!query) return true;
|
||||
return `${link.fileName}\n${link.url}\n${link.hoster}\n${link.status}\n${link.availability}`.toLocaleLowerCase("de").includes(query);
|
||||
}
|
||||
|
||||
export function buildCollectorWorkspaceViewModel(
|
||||
packages: CollectorPackage[],
|
||||
filter: CollectorWorkspaceFilter,
|
||||
query: string,
|
||||
busy: boolean,
|
||||
selectedIds: string[],
|
||||
error = ""
|
||||
): CollectorViewModel {
|
||||
const rows = buildCollectorRows(tabs, activeTabId, query);
|
||||
collapsedPackageIds: string[],
|
||||
error = "",
|
||||
animationsEnabled = true
|
||||
): CollectorWorkspaceViewModel {
|
||||
const selected = new Set(selectedIds);
|
||||
const collapsed = new Set(collapsedPackageIds);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("de");
|
||||
const allLinks = packages.flatMap((pkg) => pkg.links);
|
||||
const countAvailability = (availability: CollectorLink["availability"]): number => allLinks.filter((link) => link.availability === availability).length;
|
||||
const rows: CollectorWorkspacePackageRow[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
const packageMatches = !normalizedQuery || pkg.name.toLocaleLowerCase("de").includes(normalizedQuery);
|
||||
const visibleLinks = pkg.links.filter((link) => filterCollectorLink(link, filter)
|
||||
&& (packageMatches || collectorLinkMatchesQuery(link, normalizedQuery)));
|
||||
if (visibleLinks.length === 0) continue;
|
||||
rows.push({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
links: visibleLinks,
|
||||
allLinks: pkg.links,
|
||||
totalBytes: pkg.links.reduce((sum, link) => sum + (link.fileSizeBytes || 0), 0),
|
||||
unknownSizeCount: pkg.links.filter((link) => link.fileSizeBytes === null).length,
|
||||
onlineCount: pkg.links.filter((link) => link.availability === "online").length,
|
||||
offlineCount: pkg.links.filter((link) => link.availability === "offline").length,
|
||||
unknownCount: pkg.links.filter((link) => link.availability === "unknown").length,
|
||||
totalCount: pkg.links.length,
|
||||
selectedCount: pkg.links.filter((link) => selected.has(link.id)).length,
|
||||
collapsed: collapsed.has(pkg.id),
|
||||
addedAt: pkg.addedAt,
|
||||
hosters: [...new Set(pkg.links.map((link) => link.hoster).filter(Boolean))]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
name: tab.name,
|
||||
linkCount: nonEmptyLines(tab).length
|
||||
})),
|
||||
activeTabId,
|
||||
rows,
|
||||
busy,
|
||||
packages: rows,
|
||||
filters: [
|
||||
{ id: "all", label: "Alle Links", count: allLinks.length },
|
||||
{ id: "online", label: "Online", count: countAvailability("online") },
|
||||
{ id: "unknown", label: "Ungeprüft", count: countAvailability("unknown") },
|
||||
{ id: "offline", label: "Offline", count: countAvailability("offline") }
|
||||
],
|
||||
filter,
|
||||
query,
|
||||
selectedIds,
|
||||
busy,
|
||||
error,
|
||||
empty: rows.length === 0,
|
||||
error
|
||||
totalCount: allLinks.length,
|
||||
selectedCount: allLinks.filter((link) => selected.has(link.id)).length,
|
||||
selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id),
|
||||
animationsEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,318 +1,62 @@
|
||||
.collector-view {
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 520px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collector-view-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collector-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.collector-sidebar-heading {
|
||||
align-items: center;
|
||||
color: var(--ui-text-secondary);
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.collector-sidebar-heading span,
|
||||
.collector-sidebar-count {
|
||||
color: var(--ui-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.collector-sidebar-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.collector-sidebar-item {
|
||||
align-items: stretch;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.collector-sidebar-item:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.collector-sidebar-item.is-active {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.collector-sidebar-select,
|
||||
.collector-sidebar-remove,
|
||||
.collector-sidebar-add {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.collector-sidebar-select {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collector-sidebar-select span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-sidebar-remove {
|
||||
border-left: 1px solid var(--ui-border);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.collector-sidebar-add {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collector-sidebar-add:hover,
|
||||
.collector-sidebar-remove:hover {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.collector-toolbar {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-action {
|
||||
background: var(--ui-input);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-action:hover:not(:disabled) {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.collector-action-primary {
|
||||
background: var(--ui-primary);
|
||||
border-color: var(--ui-primary);
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-action-primary:hover:not(:disabled) {
|
||||
background: var(--ui-primary-hover);
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.collector-action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.collector-content {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collector-table {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.collector-table-header {
|
||||
height: 41px;
|
||||
}
|
||||
|
||||
.collector-table-header-row {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
height: 41px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.collector-table-header-row > span,
|
||||
.collector-row > span {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.collector-table-body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.collector-row {
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text-secondary);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.collector-row:hover {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.collector-row.is-selected {
|
||||
background: var(--ui-active);
|
||||
}
|
||||
|
||||
.collector-column-select {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.collector-row-source {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-row-value {
|
||||
color: var(--ui-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: text;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collector-row-line,
|
||||
.collector-row-status {
|
||||
color: var(--ui-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.collector-table-error .ui-data-table-empty-title {
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
|
||||
.collector-input-label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collector-input-label > span {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collector-input {
|
||||
background: var(--ui-input);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
min-height: 240px;
|
||||
padding: 12px;
|
||||
resize: vertical;
|
||||
user-select: text;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-dialog-primary,
|
||||
.collector-dialog-secondary {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.collector-dialog-primary {
|
||||
background: var(--ui-primary);
|
||||
border-color: var(--ui-primary);
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-dialog-secondary {
|
||||
background: var(--ui-modal-secondary);
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.collector-view {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.collector-sidebar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collector-action {
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
|
||||
min-width: 660px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.collector-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collector-toolbar .ui-toolbar-search {
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
||||
min-width: 610px;
|
||||
}
|
||||
}
|
||||
.collector-view { display: grid; grid-template-columns: 230px minmax(0, 1fr); min-width: 0; min-height: 520px; overflow: hidden; }
|
||||
.collector-view-main { display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 0; }
|
||||
.collector-sidebar { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 12px; }
|
||||
.collector-sidebar-heading { align-items: center; color: var(--ui-text-secondary); display: flex; font-size: 13px; justify-content: space-between; min-height: 28px; }
|
||||
.collector-sidebar-heading span { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; }
|
||||
.collector-sidebar-list { display: flex; flex: 1; flex-direction: column; gap: 4px; min-height: 0; overflow-y: auto; }
|
||||
.collector-sidebar-filter { align-items: center; background: transparent; border: 1px solid transparent; border-radius: 6px; color: var(--ui-text-secondary); display: flex; font: inherit; justify-content: space-between; min-height: 36px; padding: 0 10px; text-align: left; width: 100%; }
|
||||
.collector-sidebar-filter:hover { background: var(--ui-hover); color: var(--ui-text); }
|
||||
.collector-sidebar-filter.is-active { color: var(--ui-text); }
|
||||
.collector-sidebar-filter span:last-child { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; }
|
||||
.collector-toolbar { --collector-toolbar-action-gap: 4px; gap: var(--collector-toolbar-action-gap); min-width: 0; width: 100%; }
|
||||
.collector-toolbar .ui-toolbar-group { gap: var(--collector-toolbar-action-gap); }
|
||||
.collector-action { background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 6px; color: var(--ui-text-secondary); font: inherit; height: 36px; padding: 0 12px; white-space: nowrap; }
|
||||
.collector-action:hover:not(:disabled) { background: var(--ui-hover); color: var(--ui-text); }
|
||||
.collector-action-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
|
||||
.collector-action-primary:hover:not(:disabled) { background: var(--ui-primary-hover); color: var(--ui-primary-text); }
|
||||
.collector-action-danger:not(:disabled) { border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border)); color: var(--ui-danger-text); }
|
||||
.collector-action:disabled { cursor: default; opacity: .45; }
|
||||
.collector-content { display: flex; height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.collector-table { height: 100%; }
|
||||
.collector-table-header { height: 41px; }
|
||||
.collector-table-header-row, .collector-package-row, .collector-file-row { align-items: center; display: grid; grid-template-columns: 48px minmax(260px, 2.4fr) minmax(110px, .8fr) minmax(90px, .65fr) minmax(115px, .9fr) minmax(130px, 1fr) minmax(150px, 1fr); min-width: 960px; }
|
||||
.collector-table-header-row { color: var(--ui-text-secondary); font-size: 11px; font-weight: 700; height: 41px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.collector-table-header-row > span, .collector-package-row > span, .collector-file-row > span { min-width: 0; padding: 0 12px; }
|
||||
.collector-table-body { overflow: auto; }
|
||||
.collector-package-group { contain-intrinsic-size: 46px 654px; content-visibility: auto; }
|
||||
.collector-package-row { background: color-mix(in srgb, var(--ui-active) 34%, var(--ui-surface)); border-bottom: 1px solid var(--ui-border); color: var(--ui-text-secondary); height: 46px; }
|
||||
.collector-file-row { border-bottom: 1px solid var(--ui-border); color: var(--ui-text-secondary); height: 40px; }
|
||||
.collector-package-row:hover, .collector-file-row:hover { background: var(--ui-hover); }
|
||||
.collector-package-row.is-selected, .collector-file-row.is-selected { background: var(--ui-active); }
|
||||
.collector-package-items-frame { display: grid; grid-template-rows: 1fr; min-height: 0; opacity: 1; }
|
||||
.collector-package-items-frame.is-animated { transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1), opacity 300ms ease; }
|
||||
.collector-package-items-frame.is-collapsed { grid-template-rows: 0fr; opacity: 0; pointer-events: none; }
|
||||
.collector-package-items { min-height: 0; overflow: hidden; }
|
||||
.collector-column-select { display: grid; place-items: center; }
|
||||
.collector-name-cell { align-items: center; display: flex; gap: 8px; min-width: 0; }
|
||||
.collector-name-cell strong, .collector-name-cell.is-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.collector-name-cell small { color: var(--ui-text-muted); white-space: nowrap; }
|
||||
.collector-name-cell.is-file { color: var(--ui-text); padding-left: 36px; }
|
||||
.collector-collapse-button { align-items: center; background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 5px; color: var(--ui-text-secondary); display: inline-flex; flex: 0 0 28px; height: 28px; justify-content: center; }
|
||||
.collector-collapse-button:hover { background: var(--ui-hover); color: var(--ui-text); }
|
||||
.collector-link-state { border-radius: 50%; flex: 0 0 8px; height: 8px; }
|
||||
.collector-link-state.is-online { background: var(--ui-success); }
|
||||
.collector-link-state.is-offline { background: var(--ui-danger); }
|
||||
.collector-link-state.is-unknown { background: var(--ui-text-muted); }
|
||||
.collector-size-cell, .collector-added-cell { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.collector-hoster-cell { align-items: center; display: flex; gap: 5px; }
|
||||
.collector-hoster-cell span { color: var(--ui-text); font-weight: 600; }
|
||||
.collector-status-cell, .collector-added-cell { color: var(--ui-text-muted); }
|
||||
.collector-availability-cell { font-weight: 600; white-space: nowrap; }
|
||||
.collector-availability-cell.is-online { color: var(--ui-success); }
|
||||
.collector-availability-cell.is-offline { color: var(--ui-danger); }
|
||||
.collector-availability-cell.is-unknown { color: var(--ui-text-muted); }
|
||||
.collector-table-error .ui-data-table-empty-title { color: var(--ui-danger); }
|
||||
.collector-input-label { display: grid; gap: 8px; }
|
||||
.collector-input-label > span { color: var(--ui-text-secondary); font-size: 13px; font-weight: 600; }
|
||||
.collector-input { background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 6px; color: var(--ui-text); font: inherit; line-height: 1.5; min-height: 240px; padding: 12px; resize: vertical; user-select: text; width: 100%; }
|
||||
.collector-dialog-primary, .collector-dialog-secondary { border: 1px solid var(--ui-border); border-radius: 6px; font: inherit; height: 36px; padding: 0 14px; }
|
||||
.collector-dialog-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); }
|
||||
.collector-dialog-secondary { background: var(--ui-modal-secondary); color: var(--ui-text-secondary); }
|
||||
@media (max-width: 1366px) { .collector-view { grid-template-columns: 56px minmax(0, 1fr); } .collector-sidebar { overflow: hidden; } .collector-sidebar-filter { padding: 0 7px; } .collector-sidebar-filter span:first-child { overflow: hidden; text-overflow: ellipsis; } .collector-action { padding: 0 9px; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 44px minmax(220px, 2.2fr) minmax(98px, .8fr) minmax(78px, .65fr) minmax(100px, .9fr) minmax(112px, 1fr) minmax(132px, 1fr); min-width: 820px; } }
|
||||
@media (max-width: 1120px) { .collector-toolbar { flex-wrap: wrap; } .collector-toolbar .ui-toolbar-search { flex: 1 0 100%; width: 100%; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, .8fr) minmax(72px, .65fr) minmax(94px, .9fr) minmax(106px, 1fr) minmax(124px, 1fr); min-width: 760px; } }
|
||||
|
||||
Reference in New Issue
Block a user