Implement full UX upgrade and Rapidgator filename hardening in v1.3.2

This commit is contained in:
Sucukdeluxe
2026-02-27 14:20:54 +01:00
parent 7381e54f4f
commit 447dd7feff
15 changed files with 1597 additions and 372 deletions
+439 -314
View File
@@ -1,76 +1,49 @@
import { DragEvent, ReactElement, useEffect, useMemo, useRef, useState } from "react";
import type { AppSettings, DebridFallbackProvider, DebridProvider, DownloadItem, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
type Tab = "collector" | "downloads" | "settings";
interface CollectorTab {
id: string;
name: string;
text: string;
}
const emptyStats = (): DownloadStats => ({
totalDownloaded: 0,
totalFiles: 0,
totalPackages: 0,
sessionStartedAt: 0
});
const emptySnapshot = (): UiSnapshot => ({
settings: {
token: "",
megaLogin: "",
megaPassword: "",
bestToken: "",
allDebridToken: "",
rememberToken: true,
providerPrimary: "realdebrid",
providerSecondary: "megadebrid",
providerTertiary: "bestdebrid",
autoProviderFallback: true,
outputDir: "",
packageName: "",
autoExtract: true,
extractDir: "",
createExtractSubfolder: true,
hybridExtract: true,
cleanupMode: "none",
extractConflictMode: "overwrite",
removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false,
enableIntegrityCheck: true,
autoResumeOnStart: true,
autoReconnect: false,
reconnectWaitSeconds: 45,
completedCleanupPolicy: "never",
maxParallel: 4,
speedLimitEnabled: false,
speedLimitKbps: 0,
speedLimitMode: "global",
updateRepo: "",
autoUpdateCheck: true
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "",
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
autoExtract: true, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", bandwidthSchedules: []
},
session: {
version: 2,
packageOrder: [],
packages: {},
items: {},
runStartedAt: 0,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: Date.now()
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "",
paused: false, running: false, updatedAt: Date.now()
},
summary: null,
speedText: "Geschwindigkeit: 0 B/s",
etaText: "ETA: --",
canStart: true,
canStop: false,
canPause: false
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0
});
const cleanupLabels: Record<string, string> = {
never: "Nie",
immediate: "Sofort",
on_start: "Beim App-Start",
package_done: "Sobald Paket fertig ist"
never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist"
};
const providerLabels: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
megadebrid: "Mega-Debrid",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid"
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
};
const fallbackProviderOptions: Array<{ value: DebridFallbackProvider; label: string }> = [
@@ -86,21 +59,42 @@ function formatSpeedMbps(speedBps: number): string {
return `${mbps.toFixed(2)} MB/s`;
}
function humanSize(bytes: number): string {
if (bytes < 1024) { return `${bytes} B`; }
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
let nextCollectorId = 1;
export function App(): ReactElement {
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
const [tab, setTab] = useState<Tab>("collector");
const [linksRaw, setLinksRaw] = useState("");
const [statusToast, setStatusToast] = useState("");
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
const latestStateRef = useRef<UiSnapshot | null>(null);
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [dragOver, setDragOver] = useState(false);
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 activeCollectorTabRef = useRef(activeCollectorTab);
const draggedPackageIdRef = useRef<string | null>(null);
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
useEffect(() => {
activeCollectorTabRef.current = activeCollectorTab;
}, [activeCollectorTab]);
const showToast = (message: string, timeoutMs = 2200): void => {
setStatusToast(message);
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current);
}
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
toastTimerRef.current = setTimeout(() => {
setStatusToast("");
toastTimerRef.current = null;
@@ -109,9 +103,11 @@ export function App(): ReactElement {
useEffect(() => {
let unsubscribe: (() => void) | null = null;
let unsubClipboard: (() => void) | null = null;
void window.rd.getSnapshot().then((state) => {
setSnapshot(state);
setSettingsDraft(state.settings);
applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) {
void window.rd.checkUpdates().then((result) => {
void handleUpdateResult(result, "startup");
@@ -122,9 +118,7 @@ export function App(): ReactElement {
});
unsubscribe = window.rd.onStateUpdate((state) => {
latestStateRef.current = state;
if (stateFlushTimerRef.current) {
return;
}
if (stateFlushTimerRef.current) { return; }
stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null;
if (latestStateRef.current) {
@@ -133,18 +127,20 @@ export function App(): ReactElement {
}
}, 220);
});
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);
});
});
return () => {
if (stateFlushTimerRef.current) {
clearTimeout(stateFlushTimerRef.current);
stateFlushTimerRef.current = null;
}
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
if (unsubscribe) {
unsubscribe();
}
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
if (unsubscribe) { unsubscribe(); }
if (unsubClipboard) { unsubClipboard(); }
};
}, []);
@@ -154,124 +150,225 @@ export function App(): ReactElement {
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
if (result.error) {
if (source === "manual") {
showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800);
}
if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
return;
}
if (!result.updateAvailable) {
if (source === "manual") {
showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000);
}
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
return;
}
const approved = window.confirm(
`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`
);
if (!approved) {
showToast(`Update verfügbar: ${result.latestTag}`, 2600);
return;
}
const approved = window.confirm(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`);
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
const install = await window.rd.installUpdate();
if (install.started) {
showToast("Updater gestartet - App wird geschlossen", 2600);
return;
}
if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; }
showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200);
};
const onSaveSettings = async (): Promise<void> => {
try {
await window.rd.updateSettings(settingsDraft);
const result = await window.rd.updateSettings(settingsDraft);
setSettingsDraft(result);
applyTheme(result.theme);
showToast("Settings gespeichert", 1800);
} catch (error) {
showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800);
}
} catch (error) { showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800); }
};
const onCheckUpdates = async (): Promise<void> => {
try {
const result = await window.rd.checkUpdates();
await handleUpdateResult(result, "manual");
} catch (error) {
showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800);
}
} catch (error) { showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); }
};
const onAddLinks = async (): Promise<void> => {
try {
await window.rd.updateSettings(settingsDraft);
const result = await window.rd.addLinks({ rawText: linksRaw, packageName: settingsDraft.packageName });
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
if (result.addedLinks > 0) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
setLinksRaw("");
} else {
showToast("Keine gültigen Links gefunden");
}
} catch (error) {
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
}
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t));
} else { showToast("Keine gültigen Links gefunden"); }
} catch (error) { showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); }
};
const onImportDlc = async (): Promise<void> => {
try {
const files = await window.rd.pickContainers();
if (files.length === 0) {
return;
}
if (files.length === 0) { return; }
await window.rd.updateSettings(settingsDraft);
const result = await window.rd.addContainers(files);
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
} catch (error) {
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
}
} catch (error) { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }
};
const onDrop = async (event: DragEvent<HTMLTextAreaElement>): Promise<void> => {
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
event.preventDefault();
setDragOver(false);
const files = Array.from(event.dataTransfer.files ?? []) as File[];
const dlc = files
.filter((file) => file.name.toLowerCase().endsWith(".dlc"))
.map((file) => (file as unknown as { path?: string }).path)
.filter((value): value is string => !!value);
if (dlc.length === 0) {
return;
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 droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
if (dlc.length > 0) {
try {
await window.rd.updateSettings(settingsDraft);
const result = await window.rd.addContainers(dlc);
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
} catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }
} else if (droppedText.trim()) {
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id
? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
setTab("collector");
showToast("Links per Drag-and-Drop eingefügt");
}
};
const onExportQueue = async (): Promise<void> => {
try {
await window.rd.updateSettings(settingsDraft);
const result = await window.rd.addContainers(dlc);
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
} catch (error) {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
}
const json = await window.rd.exportQueue();
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "rd-queue-export.json";
a.click();
URL.revokeObjectURL(url);
showToast("Queue exportiert");
} catch (error) { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); }
};
const setBool = (key: keyof AppSettings, value: boolean): void => {
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
const onImportQueue = async (): Promise<void> => {
try {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) { return; }
try {
const text = await file.text();
const result = await window.rd.importQueue(text);
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
} catch (error) {
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
}
};
input.click();
} catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }
};
const setText = (key: keyof AppSettings, value: string): void => {
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
};
const setNum = (key: keyof AppSettings, value: number): void => {
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
};
const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => {
try {
await action();
} catch (error) {
showToast(`Fehler: ${String(error)}`, 2600);
}
try { await action(); } catch (error) { showToast(`Fehler: ${String(error)}`, 2600); }
};
const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
const order = [...snapshot.session.packageOrder];
const idx = order.indexOf(packageId);
if (idx < 0) { return; }
const target = direction === "up" ? idx - 1 : idx + 1;
if (target < 0 || target >= order.length) { return; }
[order[idx], order[target]] = [order[target], order[idx]];
void window.rd.reorderPackages(order);
}, [snapshot.session.packageOrder]);
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
const order = [...snapshot.session.packageOrder];
const fromIndex = order.indexOf(draggedPackageId);
const toIndex = order.indexOf(targetPackageId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
return;
}
const [dragged] = order.splice(fromIndex, 1);
const insertIndex = fromIndex < toIndex ? toIndex - 1 : toIndex;
order.splice(insertIndex, 0, dragged);
void window.rd.reorderPackages(order);
}, [snapshot.session.packageOrder]);
const addCollectorTab = (): void => {
const id = `tab-${nextCollectorId++}`;
const name = `Tab ${collectorTabs.length + 1}`;
setCollectorTabs((prev) => [...prev, { id, name, text: "" }]);
setActiveCollectorTab(id);
};
const removeCollectorTab = (id: string): void => {
setCollectorTabs((prev) => {
if (prev.length <= 1) {
return prev;
}
const index = prev.findIndex((tabEntry) => tabEntry.id === id);
if (index < 0) {
return prev;
}
const next = prev.filter((tabEntry) => tabEntry.id !== id);
if (activeCollectorTabRef.current === id) {
const fallback = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? "";
setActiveCollectorTab(fallback);
}
return next;
});
};
const onPackageDragStart = useCallback((packageId: string) => {
draggedPackageIdRef.current = packageId;
}, []);
const onPackageDrop = useCallback((targetPackageId: string) => {
const draggedPackageId = draggedPackageIdRef.current;
draggedPackageIdRef.current = null;
if (!draggedPackageId || draggedPackageId === targetPackageId) {
return;
}
reorderPackagesByDrop(draggedPackageId, targetPackageId);
}, [reorderPackagesByDrop]);
const onPackageDragEnd = useCallback(() => {
draggedPackageIdRef.current = null;
}, []);
const schedules = settingsDraft.bandwidthSchedules ?? [];
const addSchedule = (): void => {
setSettingsDraft((prev) => ({
...prev,
bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }]
}));
};
const removeSchedule = (idx: number): void => {
setSettingsDraft((prev) => ({
...prev,
bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx)
}));
};
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
setSettingsDraft((prev) => ({
...prev,
bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s)
}));
};
const applyTheme = (theme: AppTheme): void => {
document.documentElement.setAttribute("data-theme", theme);
};
const packageSpeedMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of Object.values(snapshot.session.items)) {
if (item.speedBps > 0) {
map.set(item.packageId, (map.get(item.packageId) ?? 0) + item.speedBps);
}
}
return map;
}, [snapshot]);
return (
<div className="app-shell">
<div
className={`app-shell${dragOver ? " drag-over" : ""}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
>
<header className="top-header">
<div className="title-block">
<h1>Debrid Download Manager</h1>
@@ -280,45 +377,31 @@ export function App(): ReactElement {
<div className="metrics">
<div>{snapshot.speedText}</div>
<div>{snapshot.etaText}</div>
{snapshot.reconnectSeconds > 0 && (
<div className="reconnect-badge">Reconnect: {snapshot.reconnectSeconds}s</div>
)}
</div>
</header>
<section className="control-strip">
<div className="buttons">
<button
className="btn accent"
disabled={!snapshot.canStart}
onClick={async () => {
await performQuickAction(async () => {
await window.rd.updateSettings(settingsDraft);
await window.rd.start();
});
}}
>Start</button>
<button className="btn accent" disabled={!snapshot.canStart} onClick={async () => {
await performQuickAction(async () => { await window.rd.updateSettings(settingsDraft); await window.rd.start(); });
}}>Start</button>
<button className="btn" disabled={!snapshot.canPause} onClick={() => { void performQuickAction(() => window.rd.togglePause()); }}>
{snapshot.session.paused ? "Resume" : "Pause"}
</button>
<button className="btn" disabled={!snapshot.canStop} onClick={() => { void performQuickAction(() => window.rd.stop()); }}>Stop</button>
<button className="btn" onClick={() => { void performQuickAction(() => window.rd.clearAll()); }}>Alles leeren</button>
<button className={`btn${snapshot.clipboardActive ? " btn-active" : ""}`} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
Clipboard {snapshot.clipboardActive ? "An" : "Aus"}
</button>
</div>
<div className="speed-config">
<label>
<input
type="checkbox"
checked={settingsDraft.speedLimitEnabled}
onChange={(event) => setBool("speedLimitEnabled", event.target.checked)}
/>
Speed-Limit
</label>
<input
type="number"
min={0}
max={500000}
value={settingsDraft.speedLimitKbps}
onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)}
/>
<label><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit</label>
<input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(e) => setNum("speedLimitKbps", Number(e.target.value) || 0)} />
<span>KB/s</span>
<select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}>
<select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)}>
<option value="global">global</option>
<option value="per_download">per_download</option>
</select>
@@ -335,32 +418,69 @@ export function App(): ReactElement {
{tab === "collector" && (
<section className="grid-two">
<article className="card wide">
<h3>Linksammler</h3>
<div className="link-actions">
<button className="btn" onClick={onImportDlc}>DLC import</button>
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufügen</button>
<div className="collector-header">
<h3>Linksammler</h3>
<div className="link-actions">
<button className="btn" onClick={onImportDlc}>DLC import</button>
<button className="btn" onClick={onExportQueue}>Queue Export</button>
<button className="btn" onClick={onImportQueue}>Queue Import</button>
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufugen</button>
</div>
</div>
<div className="collector-tabs">
{collectorTabs.map((ct) => (
<div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}>
<button onClick={() => setActiveCollectorTab(ct.id)}>{ct.name}</button>
{collectorTabs.length > 1 && <button className="close-tab" onClick={() => removeCollectorTab(ct.id)}>x</button>}
</div>
))}
<button className="btn add-tab" onClick={addCollectorTab}>+</button>
</div>
<textarea
value={linksRaw}
onChange={(event) => setLinksRaw(event.target.value)}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
placeholder="# package: Release-Name\nhttps://...\nhttps://..."
value={currentCollectorTab.text}
onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))}
onDragOver={(e) => e.preventDefault()}
placeholder={"# package: Release-Name\nhttps://...\nhttps://...\n\nLinks oder .dlc Dateien hier ablegen"}
/>
<p className="hint">.dlc einfach auf das Feld ziehen oder per Button importieren.</p>
</article>
</section>
)}
{tab === "downloads" && (
<section className="downloads-view">
{snapshot.reconnectSeconds > 0 && (
<div className="reconnect-banner">
Reconnect aktiv: {snapshot.reconnectSeconds}s verbleibend
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
</div>
)}
<div className="stats-bar">
<span>Pakete: {snapshot.stats.totalPackages}</span>
<span>Dateien: {snapshot.stats.totalFiles} fertig</span>
<span>Gesamt: {humanSize(snapshot.stats.totalDownloaded)}</span>
</div>
{packages.length === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>}
{packages.map((pkg) => (
{packages.map((pkg, idx) => (
<PackageCard
key={pkg.id}
pkg={pkg}
items={pkg.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean)}
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
isFirst={idx === 0}
isLast={idx === packages.length - 1}
isEditing={editingPackageId === pkg.id}
editingName={editingName}
onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }}
onFinishEdit={(name) => { setEditingPackageId(null); if (name.trim()) { void window.rd.renamePackage(pkg.id, name); } }}
onEditChange={setEditingName}
onCancel={() => { void performQuickAction(() => window.rd.cancelPackage(pkg.id)); }}
onMoveUp={() => movePackage(pkg.id, "up")}
onMoveDown={() => movePackage(pkg.id, "down")}
onToggle={() => { void window.rd.togglePackage(pkg.id); }}
onRemoveItem={(itemId) => { void window.rd.removeItem(itemId); }}
onDragStart={() => onPackageDragStart(pkg.id)}
onDrop={() => onPackageDrop(pkg.id)}
onDragEnd={onPackageDragEnd}
/>
))}
</section>
@@ -374,7 +494,14 @@ export function App(): ReactElement {
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
</div>
<div className="settings-toolbar-actions">
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
<button className="btn" onClick={onCheckUpdates}>Updates prufen</button>
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
const next = settingsDraft.theme === "dark" ? "light" : "dark";
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
applyTheme(next as AppTheme);
}}>
{settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"}
</button>
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</button>
</div>
</article>
@@ -383,148 +510,100 @@ export function App(): ReactElement {
<article className="card settings-card">
<h3>Provider & Zugang</h3>
<label>Real-Debrid API Token</label>
<input type="password" value={settingsDraft.token} onChange={(event) => setText("token", event.target.value)} />
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
<label>Mega-Debrid Login</label>
<input value={settingsDraft.megaLogin} onChange={(event) => setText("megaLogin", event.target.value)} />
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
<label>Mega-Debrid Passwort</label>
<input type="password" value={settingsDraft.megaPassword} onChange={(event) => setText("megaPassword", event.target.value)} />
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
<label>BestDebrid API Token</label>
<input type="password" value={settingsDraft.bestToken} onChange={(event) => setText("bestToken", event.target.value)} />
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
<label>AllDebrid API Key</label>
<input type="password" value={settingsDraft.allDebridToken} onChange={(event) => setText("allDebridToken", event.target.value)} />
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
<div className="field-grid three">
<div>
<label>Primär</label>
<select value={settingsDraft.providerPrimary} onChange={(event) => setText("providerPrimary", event.target.value)}>
{Object.entries(providerLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div>
<label>Sekundär</label>
<select value={settingsDraft.providerSecondary} onChange={(event) => setText("providerSecondary", event.target.value)}>
{fallbackProviderOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label>Tertiär</label>
<select value={settingsDraft.providerTertiary} onChange={(event) => setText("providerTertiary", event.target.value)}>
{fallbackProviderOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div><label>Primar</label><select value={settingsDraft.providerPrimary} onChange={(e) => setText("providerPrimary", e.target.value)}>
{Object.entries(providerLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
</select></div>
<div><label>Sekundar</label><select value={settingsDraft.providerSecondary} onChange={(e) => setText("providerSecondary", e.target.value)}>
{fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
</select></div>
<div><label>Tertiar</label><select value={settingsDraft.providerTertiary} onChange={(e) => setText("providerTertiary", e.target.value)}>
{fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
</select></div>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(event) => setBool("autoProviderFallback", event.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(event) => setBool("rememberToken", event.target.checked)} /> Zugangsdaten lokal speichern</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nachsten Provider wechseln</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
</article>
<article className="card settings-card">
<h3>Pfade & Paketierung</h3>
<label>Download-Ordner</label>
<div className="input-row">
<input value={settingsDraft.outputDir} onChange={(event) => setText("outputDir", event.target.value)} />
<button
className="btn"
onClick={() => {
void performQuickAction(async () => {
const selected = await window.rd.pickFolder();
if (selected) {
setText("outputDir", selected);
}
});
}}
>Wählen</button>
<input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} />
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wahlen</button>
</div>
<label>Paketname (optional)</label>
<input value={settingsDraft.packageName} onChange={(event) => setText("packageName", event.target.value)} />
<input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} />
<label>Entpacken nach</label>
<div className="input-row">
<input value={settingsDraft.extractDir} onChange={(event) => setText("extractDir", event.target.value)} />
<button
className="btn"
onClick={() => {
void performQuickAction(async () => {
const selected = await window.rd.pickFolder();
if (selected) {
setText("extractDir", selected);
}
});
}}
>Wählen</button>
<input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} />
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wahlen</button>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(event) => setBool("autoExtract", event.target.checked)} /> Auto-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(event) => setBool("hybridExtract", event.target.checked)} /> Hybrid-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
</article>
<article className="card settings-card">
<h3>Queue, Limits & Reconnect</h3>
<div className="field-grid two">
<div>
<label>Max. Downloads</label>
<input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(event) => setNum("maxParallel", Number(event.target.value) || 1)} />
</div>
<div>
<label>Reconnect-Wartezeit (Sek.)</label>
<input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(event) => setNum("reconnectWaitSeconds", Number(event.target.value) || 45)} />
</div>
<div><label>Max. Downloads</label><input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(e) => setNum("maxParallel", Number(e.target.value) || 1)} /></div>
<div><label>Reconnect-Wartezeit (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Number(e.target.value) || 45)} /></div>
</div>
<div className="field-grid two">
<div>
<label>Speed-Limit (KB/s)</label>
<input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit aktivieren</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatischer Reconnect</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage uberwachen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
<h4>Bandbreitenplanung</h4>
{schedules.map((s, i) => (
<div key={i} className="schedule-row">
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" />
<span>-</span>
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
<span>Uhr</span>
<input type="number" min={0} value={s.speedLimitKbps} onChange={(e) => updateSchedule(i, "speedLimitKbps", Number(e.target.value) || 0)} title="KB/s (0=unbegrenzt)" />
<span>KB/s</span>
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
</div>
<div>
<label>Speed-Modus</label>
<select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}>
<option value="global">global</option>
<option value="per_download">per_download</option>
</select>
</div>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(event) => setBool("speedLimitEnabled", event.target.checked)} /> Speed-Limit aktivieren</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(event) => setBool("autoReconnect", event.target.checked)} /> Automatischer Reconnect</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(event) => setBool("autoResumeOnStart", event.target.checked)} /> Auto-Resume beim Start</label>
))}
<button className="btn" onClick={addSchedule}>Zeitregel hinzufugen</button>
</article>
<article className="card settings-card">
<h3>Integrität, Cleanup & Updates</h3>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(event) => setBool("enableIntegrityCheck", event.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(event) => setBool("removeLinkFilesAfterExtract", event.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(event) => setBool("removeSamplesAfterExtract", event.target.checked)} /> Samples nach Entpacken entfernen</label>
<h3>Integritat, Cleanup & Updates</h3>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> SFV/CRC/MD5/SHA1 prufen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Samples nach Entpacken entfernen</label>
<label>Fertiggestellte Downloads entfernen</label>
<select value={settingsDraft.completedCleanupPolicy} onChange={(event) => setText("completedCleanupPolicy", event.target.value)}>
{Object.entries(cleanupLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
<select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}>
{Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
</select>
<div className="field-grid two">
<div>
<label>Cleanup nach Entpacken</label>
<select value={settingsDraft.cleanupMode} onChange={(event) => setText("cleanupMode", event.target.value)}>
<option value="none">keine Archive löschen</option>
<option value="trash">Archive in Papierkorb</option>
<option value="delete">Archive löschen</option>
</select>
</div>
<div>
<label>Konfliktmodus</label>
<select value={settingsDraft.extractConflictMode} onChange={(event) => setText("extractConflictMode", event.target.value)}>
<option value="overwrite">überschreiben</option>
<option value="skip">überspringen</option>
<option value="rename">umbenennen</option>
<option value="ask">nachfragen</option>
</select>
</div>
<div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}>
<option value="none">keine Archive loschen</option>
<option value="trash">Archive in Papierkorb</option>
<option value="delete">Archive loschen</option>
</select></div>
<div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
<option value="overwrite">uberschreiben</option>
<option value="skip">uberspringen</option>
<option value="rename">umbenennen</option>
<option value="ask">nachfragen</option>
</select></div>
</div>
<label>GitHub Repo</label>
<input value={settingsDraft.updateRepo} onChange={(event) => setText("updateRepo", event.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(event) => setBool("autoUpdateCheck", event.target.checked)} /> Beim Start auf Updates prüfen</label>
<input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start auf Updates prufen</label>
</article>
</section>
</section>
@@ -532,40 +611,85 @@ export function App(): ReactElement {
</main>
{statusToast && <div className="toast">{statusToast}</div>}
{dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>}
</div>
);
}
function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: DownloadItem[]; onCancel: () => void }): ReactElement {
interface PackageCardProps {
pkg: PackageEntry;
items: DownloadItem[];
packageSpeed: number;
isFirst: boolean;
isLast: boolean;
isEditing: boolean;
editingName: string;
onStartEdit: () => void;
onFinishEdit: (name: string) => void;
onEditChange: (name: string) => void;
onCancel: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onToggle: () => void;
onRemoveItem: (itemId: string) => void;
onDragStart: () => void;
onDrop: () => void;
onDragEnd: () => void;
}
function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, onStartEdit, onFinishEdit, onEditChange, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
const done = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length;
const total = Math.max(1, items.length);
const progress = Math.floor((done / total) * 100);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") { onFinishEdit(editingName); }
if (e.key === "Escape") { onFinishEdit(pkg.name); }
};
return (
<article className="package-card">
<article
className={`package-card${pkg.enabled ? "" : " disabled-pkg"}`}
draggable
onDragStart={(event) => { event.stopPropagation(); onDragStart(); }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(); }}
onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }}
>
<header>
<div>
<h4>{pkg.name}</h4>
<span>{done}/{total} fertig · {failed} Fehler · {cancelled} abgebrochen</span>
<div className="pkg-info">
<div className="pkg-name-row">
<input type="checkbox" checked={pkg.enabled} onChange={onToggle} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} />
{isEditing ? (
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(editingName)} onKeyDown={onKeyDown} autoFocus />
) : (
<h4 onDoubleClick={onStartEdit} title="Doppelklick zum Umbenennen">{pkg.name}</h4>
)}
</div>
<span>{done}/{total} fertig {failed > 0 && `· ${failed} Fehler `}{cancelled > 0 && `· ${cancelled} abgebrochen `}
{packageSpeed > 0 && <span className="pkg-speed">{formatSpeedMbps(packageSpeed)}</span>}
</span>
</div>
<div className="pkg-actions">
<button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">&#9650;</button>
<button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">&#9660;</button>
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
<button className="btn danger" onClick={onCancel}>Paket abbrechen</button>
</div>
<button className="btn danger" onClick={onCancel}>Paket abbrechen</button>
</header>
<div className="progress">
<div style={{ width: `${progress}%` }} />
</div>
<div className="progress"><div style={{ width: `${progress}%` }} /></div>
<table>
<thead>
<tr>
<th className="col-file">Datei</th>
<th className="col-provider">Provider</th>
<th className="col-status">Status</th>
<th className="col-progress">Fortschritt</th>
<th className="col-speed">Speed</th>
<th className="col-retries">Retries</th>
</tr>
</thead>
<thead><tr>
<th className="col-file">Datei</th>
<th className="col-provider">Provider</th>
<th className="col-status">Status</th>
<th className="col-progress">Fortschritt</th>
<th className="col-speed">Speed</th>
<th className="col-retries">Retries</th>
<th className="col-actions">Aktion</th>
</tr></thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
@@ -575,6 +699,7 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl
<td className="col-progress num">{item.progressPercent}%</td>
<td className="col-speed num">{formatSpeedMbps(item.speedBps)}</td>
<td className="col-retries num">{item.retries}</td>
<td className="col-actions"><button className="btn-icon danger" onClick={() => onRemoveItem(item.id)} title="Entfernen">X</button></td>
</tr>
))}
</tbody>
+249 -13
View File
@@ -1,7 +1,7 @@
:root {
color-scheme: dark;
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
--bg: #040912;
--bg-glow: #10203b;
--surface: #0b1424;
--card: #101d31;
--field: #081120;
@@ -10,6 +10,40 @@
--muted: #90a4bf;
--accent: #38bdf8;
--danger: #f43f5e;
--button-bg: #0d1a2c;
--button-bg-hover: #12243d;
--tab-bg: #0b1321;
--tab-active: #14253e;
--toast-bg: #0f1f33;
--toast-border: #2a4f78;
--progress-track: #0b1628;
--overlay: rgba(8, 13, 23, 0.85);
}
:root[data-theme="dark"] {
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #eef3fb;
--bg-glow: #d7e5ff;
--surface: #f7faff;
--card: #ffffff;
--field: #ffffff;
--border: #c7d5ea;
--text: #0f223d;
--muted: #4e6482;
--accent: #1168d9;
--danger: #c0392b;
--button-bg: #f3f7ff;
--button-bg-hover: #e6efff;
--tab-bg: #edf3ff;
--tab-active: #dce8ff;
--toast-bg: #ffffff;
--toast-border: #a9c1e8;
--progress-track: #dfe8f8;
--overlay: rgba(229, 238, 252, 0.88);
}
* {
@@ -22,7 +56,7 @@ body,
margin: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 15% 10%, #10203b 0, #050b15 45%, #040912 100%);
background: radial-gradient(circle at 15% 10%, var(--bg-glow) 0, var(--surface) 45%, var(--bg) 100%);
color: var(--text);
}
@@ -63,7 +97,7 @@ body,
display: flex;
justify-content: space-between;
gap: 12px;
background: linear-gradient(180deg, rgba(20, 34, 56, 0.95), rgba(9, 16, 28, 0.95));
background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 92%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 12px;
@@ -80,7 +114,7 @@ body,
}
.btn {
background: #0d1a2c;
background: var(--button-bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 9px;
@@ -99,7 +133,7 @@ body,
.btn:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--accent);
background: #12243d;
background: var(--button-bg-hover);
}
.btn.accent {
@@ -113,13 +147,23 @@ body,
color: #fda4af;
}
:root[data-theme="light"] .btn.danger {
border-color: color-mix(in srgb, var(--danger) 60%, transparent);
color: var(--danger);
}
.btn.btn-active {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
.tabs {
display: flex;
gap: 8px;
}
.tab {
background: #0b1321;
background: var(--tab-bg);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 9px;
@@ -131,8 +175,8 @@ body,
.tab.active {
color: var(--text);
background: #14253e;
border-color: #2c4e77;
background: var(--tab-active);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.tab-content {
@@ -149,7 +193,7 @@ body,
.card {
border: 1px solid var(--border);
border-radius: 12px;
background: linear-gradient(180deg, rgba(17, 29, 49, 0.95), rgba(9, 16, 28, 0.95));
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent));
padding: 10px;
display: flex;
flex-direction: column;
@@ -212,6 +256,82 @@ body,
gap: 10px;
}
.collector-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.collector-tabs {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.collector-tab {
display: flex;
align-items: center;
border: 1px solid var(--border);
border-radius: 9px;
overflow: hidden;
background: var(--tab-bg);
}
.collector-tab > button {
border: 0;
background: transparent;
color: var(--muted);
padding: 6px 10px;
cursor: pointer;
}
.collector-tab.active > button {
color: var(--text);
}
.collector-tab .close-tab {
border: 0;
border-left: 1px solid var(--border);
background: transparent;
color: var(--muted);
width: 26px;
cursor: pointer;
}
.collector-tab .close-tab:hover {
color: var(--danger);
}
.add-tab {
min-width: 32px;
padding-inline: 0;
}
.reconnect-badge {
color: color-mix(in srgb, var(--danger) 70%, var(--text));
font-weight: 600;
}
.reconnect-banner {
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
color: var(--text);
border-radius: 10px;
padding: 8px 10px;
font-size: 13px;
}
.stats-bar {
display: flex;
flex-wrap: wrap;
gap: 14px;
color: var(--muted);
font-size: 13px;
}
.settings-shell {
display: grid;
grid-template-rows: auto 1fr;
@@ -246,6 +366,8 @@ body,
}
.settings-grid {
display: grid;
gap: 10px;
min-height: 0;
overflow: auto;
align-content: start;
@@ -286,10 +408,22 @@ body,
.package-card {
border: 1px solid var(--border);
border-radius: 14px;
background: linear-gradient(180deg, rgba(16, 29, 48, 0.95), rgba(7, 13, 22, 0.95));
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent));
padding: 12px;
}
.package-card[draggable="true"] {
cursor: grab;
}
.package-card[draggable="true"]:active {
cursor: grabbing;
}
.disabled-pkg {
opacity: 0.72;
}
.package-card header {
display: flex;
justify-content: space-between;
@@ -307,11 +441,43 @@ body,
font-size: 13px;
}
.pkg-info {
min-width: 0;
}
.pkg-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.pkg-name-row input[type="checkbox"] {
width: 16px;
height: 16px;
}
.rename-input {
min-width: 220px;
max-width: 440px;
}
.pkg-speed {
margin-left: 7px;
color: color-mix(in srgb, var(--accent) 70%, var(--text));
}
.pkg-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.progress {
margin-top: 8px;
height: 7px;
border-radius: 999px;
background: #0b1628;
background: var(--progress-track);
overflow: hidden;
}
@@ -379,6 +545,39 @@ td {
grid-template-columns: 1.1fr 1fr;
}
.schedule-row {
display: grid;
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
align-items: center;
gap: 6px;
}
.schedule-row input[type="number"] {
text-align: center;
}
.schedule-row .btn {
padding: 5px 8px;
}
.btn-icon {
border: 1px solid var(--border);
background: var(--button-bg);
color: var(--text);
border-radius: 7px;
width: 28px;
height: 28px;
cursor: pointer;
}
.btn-icon.danger {
color: var(--danger);
}
.col-actions {
width: 8%;
}
.empty {
border: 1px dashed var(--border);
border-radius: 12px;
@@ -391,20 +590,39 @@ td {
position: fixed;
right: 20px;
bottom: 18px;
background: #0f1f33;
background: var(--toast-bg);
color: var(--text);
border: 1px solid #2a4f78;
border: 1px solid var(--toast-border);
border-radius: 12px;
padding: 10px 14px;
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
}
.drop-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: var(--overlay);
border: 2px dashed color-mix(in srgb, var(--accent) 72%, transparent);
color: var(--text);
font-size: 20px;
font-weight: 600;
pointer-events: none;
backdrop-filter: blur(2px);
}
@media (max-width: 1100px) {
.control-strip {
flex-direction: column;
align-items: flex-start;
}
.metrics {
flex-direction: column;
align-items: flex-end;
}
.settings-toolbar {
flex-direction: column;
align-items: flex-start;
@@ -424,6 +642,24 @@ td {
grid-template-columns: 1fr;
}
.schedule-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.schedule-row span {
display: none;
}
.package-card header {
flex-direction: column;
align-items: flex-start;
}
.pkg-actions {
width: 100%;
justify-content: flex-start;
}
.card.wide,
.settings-actions {
grid-column: span 1;