diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e7a2ca..d97430e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,36 @@
All notable changes to Multi-Debrid Downloader are documented in this file.
+## [2.0.21] - 2026-08-11
+
+### Responsive interface
+
+- Refined Downloads, Link Collector, Settings, History, and Statistics layouts for 1920, 1366, and 1120 pixel window widths.
+- Kept the Downloads action column reachable in compact windows while preserving useful space for names, status, service, speed, and availability.
+- Moved the compact sidebar control into reserved header space so it no longer overlaps view content.
+- Wrapped Link Collector search and actions cleanly at narrow widths without creating global horizontal scrolling.
+- Reworked History pagination to keep the page size, information control, range, and navigation visible without overlap.
+
+### Interaction and accessibility
+
+- Added keyboard controls for moving Download columns and exposed sort state, mixed selection state, and active filters to assistive technology.
+- Added keyboard-complete custom selectors, theme choices, account tabs, loading announcements, error announcements, and empty-search feedback in Settings.
+- Added confirmation before removing collections or selected collection links, unique link-selection labels, and disabled empty collection submission.
+- Added confirmation before resetting session or all-time statistics and accessible labels for the live bandwidth chart.
+- Completed English localization for new pagination, account feedback, link-selection, and copy-control accessibility text.
+- Replaced clickable text-only copy targets with native buttons and improved focus, control-border, success, warning, and danger contrast in both themes.
+
+### Visual consistency
+
+- Changed the live header speed graph and Statistics bandwidth line to the shared success green.
+- Improved table-heading, progress, availability, account-status, and destructive-action contrast across all primary views.
+- Preserved full service and status details through labels and tooltips when compact layouts require ellipsis.
+
+### Reliability and testing
+
+- Added regression coverage for compact table visibility, keyboard column movement, destructive confirmations, History pagination, accessible copy actions, Settings control states, and the updated chart colors.
+- Expanded the visual verification matrix across every primary view at all supported audit widths.
+
## [2.0.20] - 2026-08-11
### Downloads and selection
diff --git a/package-lock.json b/package-lock.json
index ece4042..675ab4f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
- "version": "2.0.20",
+ "version": "2.0.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
- "version": "2.0.20",
+ "version": "2.0.21",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
diff --git a/package.json b/package.json
index 3a7b5c3..b2d130f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
- "version": "2.0.20",
+ "version": "2.0.21",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 6fbcd68..776fd59 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1317,6 +1317,14 @@ export function readBandwidthChartPalette(
};
}
+export function readDownloadSpeedSparklinePalette(
+ readProperty: (property: string) => string
+): { accent: string } {
+ return {
+ accent: readProperty("--ui-speed-accent").trim()
+ };
+}
+
export function appendBandwidthSample(
history: { time: number; speed: number }[],
speed: number,
@@ -1462,14 +1470,15 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
ctx.fill();
}, [running, paused]);
- useEffect(() => {
- drawChart();
- if (!running || paused) {
- return;
- }
- const interval = setInterval(() => {
- drawChart();
- }, 250);
+ useEffect(() => {
+ drawChart();
+ if (!running || paused) {
+ return;
+ }
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const interval = setInterval(() => {
+ drawChart();
+ }, reducedMotion ? 1000 : 250);
return () => clearInterval(interval);
}, [drawChart, running, paused]);
@@ -1492,11 +1501,13 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
drawChart();
}, [drawChart, paused]);
- return (
-
-
-
- );
+ return (
+
+
+
+ );
});
interface DownloadSpeedSparklineProps {
@@ -1528,9 +1539,8 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
const hist = speedStateRef.current.history;
if (hist.length < 2) return;
- const isDark = document.documentElement.getAttribute("data-theme") !== "light";
- const accent = isDark ? "#f2942d" : "#c2701a";
- const fill = isDark ? "rgba(242, 148, 45, 0.16)" : "rgba(194, 112, 26, 0.16)";
+ const rootStyle = getComputedStyle(document.documentElement);
+ const palette = readDownloadSpeedSparklinePalette((property) => rootStyle.getPropertyValue(property));
let maxV = 0;
for (const v of hist) if (v > maxV) maxV = v;
@@ -1549,13 +1559,16 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
ctx.lineTo(px(hist.length - 1), pad + h);
ctx.lineTo(px(0), pad + h);
ctx.closePath();
- ctx.fillStyle = fill;
- ctx.fill();
+ ctx.save();
+ ctx.globalAlpha = 0.16;
+ ctx.fillStyle = palette.accent;
+ ctx.fill();
+ ctx.restore();
ctx.beginPath();
ctx.moveTo(px(0), py(hist[0]));
for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i]));
- ctx.strokeStyle = accent;
+ ctx.strokeStyle = palette.accent;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.stroke();
@@ -3883,20 +3896,37 @@ export function App(): ReactElement {
};
const removeCollectorTab = (id: string): void => {
- const removal = planCollectorTabRemoval(
- collectorTabsRef.current,
- activeCollectorTabRef.current,
- id
- );
- if (removal.tabs === collectorTabsRef.current) {
+ const tab = collectorTabsRef.current.find((entry) => entry.id === id);
+ if (!tab || collectorTabsRef.current.length <= 1) {
return;
}
- collectorTabsRef.current = removal.tabs;
- activeCollectorTabRef.current = removal.activeTabId;
- setCollectorTabs(removal.tabs);
- setActiveCollectorTab(removal.activeTabId);
- setSelectedCollectorRowIds(new Set());
- setCollectorError("");
+ 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 => {
@@ -3957,11 +3987,24 @@ export function App(): ReactElement {
indexes.add(index);
}
}
- 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());
- setCollectorError("");
+ if (indexes.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.",
+ confirmLabel: "Links löschen",
+ danger: true
+ }).then((confirmed) => {
+ 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());
+ setCollectorError("");
+ });
};
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
@@ -5067,17 +5110,37 @@ export function App(): ReactElement {
const statisticsActions: StatisticsViewActions = {
onRangeChange: setStatisticsRange,
onResetSession: () => {
- void window.rd.resetSessionStats().then(() => {
- showToast("Session-Statistik zurückgesetzt", 1800);
- }).catch((error) => {
- showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400);
+ void askConfirmPrompt({
+ title: "Sitzungsstatistik zurücksetzen",
+ message: "Die Zähler, Downloadmenge und Geschwindigkeitsdaten der aktuellen Sitzung werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.",
+ confirmLabel: "Sitzung zurücksetzen",
+ danger: true
+ }).then((confirmed) => {
+ if (!confirmed) {
+ return;
+ }
+ return window.rd.resetSessionStats().then(() => {
+ showToast("Session-Statistik zurückgesetzt", 1800);
+ }).catch((error) => {
+ showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400);
+ });
});
},
onResetAll: () => {
- void window.rd.resetDownloadStats().then(() => {
- showToast("Gesamt-Downloadstatistik zurückgesetzt", 1800);
- }).catch((error) => {
- showToast(`Download-Reset fehlgeschlagen: ${String(error)}`, 2400);
+ void askConfirmPrompt({
+ title: "Gesamtstatistik zurücksetzen",
+ message: "Alle dauerhaft gespeicherten Download- und Providerstatistiken werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.",
+ confirmLabel: "Gesamt zurücksetzen",
+ danger: true
+ }).then((confirmed) => {
+ if (!confirmed) {
+ return;
+ }
+ return window.rd.resetDownloadStats().then(() => {
+ showToast("Gesamt-Downloadstatistik zurückgesetzt", 1800);
+ }).catch((error) => {
+ showToast(`Download-Reset fehlgeschlagen: ${String(error)}`, 2400);
+ });
});
},
onResetErrors: () => {
@@ -6457,17 +6520,19 @@ export function App(): ReactElement {
return (
<>
{ki + 1}
- {
+
+ .catch(() => showToast("Kopieren fehlgeschlagen", 2200));
+ }}
+ >
+ {key.masked}
+
{humanSize(key.dailyUsedBytes)}
{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}
{statusDisplay.label}
@@ -6519,8 +6584,8 @@ export function App(): ReactElement {
{linkPopup.links.map((link, i) => (
- { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}
- { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}
+
+
))}
diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts
index 4889abd..2db91ab 100644
--- a/src/renderer/i18n.ts
+++ b/src/renderer/i18n.ts
@@ -53,7 +53,7 @@ const pairs = [
["Alle Einträge", "All entries"], ["Heute", "Today"], ["Letzte 7 Tage", "Last 7 days"], ["Älter", "Older"], ["Gelöscht", "Deleted"], ["Fehlgeschlagen", "Failed"],
["Dauer", "Duration"], ["Durchschnitt", "Average"], ["Zielordner", "Destination folder"], ["Verlaufsfilter", "History filters"], ["Verlauf leeren", "Clear history"], ["Verlaufsaktionen", "History actions"],
["Einträge", "Entries"], ["Erneut hinzufügen", "Add again"], ["Im Ordner zeigen", "Show in folder"], ["Auswahl löschen", "Clear selection"], ["Verlauf durchsuchen", "Search history"], ["Name, Pfad, Hoster oder Provider", "Name, path, hoster or provider"],
- ["Verlaufstabelle", "History table"], ["Verlauf wird geladen", "Loading history"], ["Die gespeicherten Einträge werden geladen.", "Saved entries are being loaded."], ["Noch kein Verlauf", "No history yet"], ["Keine passenden Einträge", "No matching entries"],
+ ["Verlaufstabelle", "History table"], ["Verlaufsseiten", "History pages"], ["Vorherige Verlaufsseite", "Previous history page"], ["Nächste Verlaufsseite", "Next history page"], ["Zurück", "Back"], ["Vor", "Next"], ["Verlauf wird geladen", "Loading history"], ["Die gespeicherten Einträge werden geladen.", "Saved entries are being loaded."], ["Verlauf wird geladen. Die gespeicherten Einträge werden geladen.", "History is loading. Saved entries are being loaded."], ["Noch kein Verlauf", "No history yet"], ["Keine passenden Einträge", "No matching entries"],
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
@@ -62,16 +62,16 @@ const pairs = [
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"],
- ["Bandbreitenverlauf", "Bandwidth history"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"],
+ ["Bandbreitenverlauf", "Bandwidth history"], ["Bandbreitenverlauf der letzten 60 Sekunden", "Bandwidth history for the last 60 seconds"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"],
["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"],
["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"],
- ["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Gesammelte Links", "Collected links"],
+ ["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"],
["Link auswählen", "Select link"], ["Lokal", "Local"], ["Übernehmen", "Apply"], ["Eine URL oder Rohzeile pro Zeile", "One URL or raw line per line"],
- ["Die Accountdaten werden aktualisiert.", "Account data is being updated."], ["Accounts werden geladen", "Loading accounts"], ["Die gespeicherten Accounts bleiben unverändert.", "Saved accounts remain unchanged."],
+ ["Die Accountdaten werden aktualisiert.", "Account data is being updated."], ["Accountdaten werden aktualisiert.", "Account data is being updated."], ["Accounts werden geladen", "Loading accounts"], ["Die gespeicherten Accounts bleiben unverändert.", "Saved accounts remain unchanged."], ["Keine passenden Dienste oder Zugangstypen gefunden.", "No matching services or access types found."],
["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."],
["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."],
["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"],
@@ -96,7 +96,8 @@ const pairs = [
["Sitzungszähler und Ergebnisse des zuletzt beendeten Laufs werden angezeigt.", "Session counters and results from the last completed run are shown."], ["Nicht verfügbar", "Unavailable"],
["Heute wurden noch keine Providerbytes erfasst.", "No provider bytes have been recorded today."], ["Noch keine gespeicherten Providerbytes vorhanden.", "No stored provider bytes available yet."],
["In der aktuellen Queue sind noch keine Providerwerte vorhanden.", "No provider values are available in the current queue yet."], ["Statistik-Zeitraum", "Statistics time range"], ["Statistik-Dashboard", "Statistics dashboard"],
- ["Statistiken zurücksetzen", "Reset statistics"], ["Erfolgsquote", "Success rate"], ["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
+ ["Statistiken zurücksetzen", "Reset statistics"], ["Sitzungsstatistik zurücksetzen", "Reset session statistics"], ["Die Zähler, Downloadmenge und Geschwindigkeitsdaten der aktuellen Sitzung werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.", "The counters, download volume, and speed data for the current session will be deleted. This action cannot be undone."], ["Gesamtstatistik zurücksetzen", "Reset total statistics"], ["Alle dauerhaft gespeicherten Download- und Providerstatistiken werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.", "All permanently stored download and provider statistics will be deleted. This action cannot be undone."], ["Erfolgsquote", "Success rate"], ["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
+ ["Sammlung entfernen", "Remove collection"],
["Kontextmenü", "Context menu"], ["Die Oberfläche hat einen Fehler ausgelöst", "The interface encountered an error"],
["Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.", "The interface was stopped to prevent data loss. Downloads running in the background are not affected. The error was written to the log."],
["Oberfläche neu laden", "Reload interface"], ["Unbekannter Fehler", "Unknown error"],
@@ -216,7 +217,7 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (language === "en") {
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
- const pagination = value.match(/^(\d+\s*[–-]\s*\d+) von (\d+)$/);
+ const pagination = value.match(/^([\d.,]+\s*[–-]\s*[\d.,]+) von ([\d.,]+)$/);
if (pagination) return `${pagination[1]} of ${pagination[2]}`;
const schedule = value.match(/^Zeitregel (\d+)$/);
if (schedule) return `Schedule rule ${schedule[1]}`;
@@ -228,6 +229,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (labelledCount) return `${({ Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as const)[labelledCount[1] as "Einträge" | "Sichtbar" | "Ausgewählt"]}: ${labelledCount[2]}`;
const perPage = value.match(/^(\d+) pro Seite$/);
if (perPage) return `${perPage[1]} per page`;
+ const pageStatus = value.match(/^Seite ([\d.,\s]+) von ([\d.,\s]+)$/);
+ if (pageStatus) return `Page ${pageStatus[1]} of ${pageStatus[2]}`;
const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/);
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
const remaining = value.match(/^(.+) von (.+) übrig$/);
@@ -292,7 +295,9 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (warnings) return `${warnings[1]} errors, ${warnings[2]} warnings (latest ${warnings[3]})`;
const capture = value.match(/^Links für (.+) lokal erfassen\.$/);
if (capture) return `Capture links locally for ${capture[1]}.`;
- const copy = value.match(/^(.+) Klicken zum Kopieren$/);
+ const collectorSelection = value.match(/^(.+) aus (.+), Zeile (\d+) auswählen$/);
+ if (collectorSelection) return `Select ${collectorSelection[1]} from ${collectorSelection[2]}, line ${collectorSelection[3]}`;
+ const copy = value.match(/^([\s\S]+?)\s+Klicken zum Kopieren$/);
if (copy) return `Click to copy ${copy[1]}`;
const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
if (cancelledPart) return `· ${cancelledPart[1]} cancelled`;
@@ -340,11 +345,21 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (toggleAll) return `Toggle all ${toggleAll[1]}`;
const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/);
if (removeSelected) return `Remove selected (${removeSelected[1]})`;
+ const removeCollection = value.match(/^Soll die Sammlung (.+) mit (\d+) Link\(s\) wirklich entfernt werden\?$/);
+ if (removeCollection) return `Do you really want to remove collection ${removeCollection[1]} with ${removeCollection[2]} link(s)?`;
+ const removeEmptyCollection = value.match(/^Soll die leere Sammlung (.+) wirklich entfernt werden\?$/);
+ if (removeEmptyCollection) return `Do you really want to remove the empty collection ${removeEmptyCollection[1]}?`;
+ const copyTitle = value.match(/^([\s\S]+?)\s+Klicken zum Kopieren$/);
+ if (copyTitle) return `Click to copy ${copyTitle[1]}`;
+ const moveColumnDirection = value.match(/^(.+) nach (links|rechts) verschieben$/);
+ if (moveColumnDirection) return `Move ${deToEn.get(moveColumnDirection[1]) ?? moveColumnDirection[1]} ${moveColumnDirection[2] === "links" ? "left" : "right"}`;
+ const moveColumn = value.match(/^(.+) verschieben$/);
+ if (moveColumn) return `Move ${deToEn.get(moveColumn[1]) ?? moveColumn[1]}`;
const copied = value.match(/^(.+) kopiert$/);
if (copied) return `${copied[1]} copied`;
const suffixes: Array<[RegExp, string]> = [
[/^(.+) auswählen$/, "Select $1"], [/^(.+) einklappen$/, "Collapse $1"], [/^(.+) ausklappen$/, "Expand $1"],
- [/^(.+) aktivieren$/, "Enable $1"], [/^(.+) deaktivieren$/, "Disable $1"], [/^(.+) Aktionen$/, "$1 actions"], [/^(.+) entfernen$/, "Remove $1"]
+ [/^(.+) aktivieren$/, "Enable $1"], [/^(.+) deaktivieren$/, "Disable $1"], [/^(.+) Aktionen$/, "$1 actions"], [/^(.+) entfernen$/, "Remove $1"], [/^(.+) kopieren$/, "Copy $1"]
];
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
return value
@@ -355,7 +370,7 @@ function translateDynamic(value: string, language: AppLanguage): string {
} else {
const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/);
if (update) return `${update[1]} ist verfügbar. Installierte Version: ${update[2]}.`;
- const pagination = value.match(/^(\d+\s*[–-]\s*\d+) of (\d+)$/);
+ const pagination = value.match(/^([\d.,]+\s*[–-]\s*[\d.,]+) of ([\d.,]+)$/);
if (pagination) return `${pagination[1]} von ${pagination[2]}`;
const schedule = value.match(/^Schedule rule (\d+)$/);
if (schedule) return `Zeitregel ${schedule[1]}`;
@@ -469,6 +484,18 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (historyQuestion) return `${historyQuestion[1]} Einträge aus dem Verlauf entfernen?`;
const historyRemoved = value.match(/^(\d+) history entries removed$/);
if (historyRemoved) return `${historyRemoved[1]} Verlaufseinträge entfernt`;
+ const removeCollection = value.match(/^Do you really want to remove collection (.+) with (\d+) link\(s\)\?$/);
+ if (removeCollection) return `Soll die Sammlung ${removeCollection[1]} mit ${removeCollection[2]} Link(s) wirklich entfernt werden?`;
+ const removeEmptyCollection = value.match(/^Do you really want to remove the empty collection (.+)\?$/);
+ if (removeEmptyCollection) return `Soll die leere Sammlung ${removeEmptyCollection[1]} wirklich entfernt werden?`;
+ const pageStatus = value.match(/^Page ([\d.,\s]+) of ([\d.,\s]+)$/);
+ 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 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 (.+)$/);
+ if (moveColumn) return `${enToDe.get(moveColumn[1]) ?? moveColumn[1]} verschieben`;
const packageCount = value.match(/^(\d+) package\(s\)$/);
if (packageCount) return `${packageCount[1]} Paket(e)`;
const linkCount = value.match(/^(\d+) link\(s\)$/);
@@ -483,7 +510,7 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (copied) return `${copied[1]} kopiert`;
const suffixes: Array<[RegExp, string]> = [
[/^Select (.+)$/, "$1 auswählen"], [/^Collapse (.+)$/, "$1 einklappen"], [/^Expand (.+)$/, "$1 ausklappen"],
- [/^Enable (.+)$/, "$1 aktivieren"], [/^Disable (.+)$/, "$1 deaktivieren"], [/^(.+) actions$/, "$1 Aktionen"], [/^Remove (.+)$/, "$1 entfernen"]
+ [/^Enable (.+)$/, "$1 aktivieren"], [/^Disable (.+)$/, "$1 deaktivieren"], [/^(.+) actions$/, "$1 Aktionen"], [/^Remove (.+)$/, "$1 entfernen"], [/^Copy (.+)$/, "$1 kopieren"]
];
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
return value
diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css
index 606548e..17060ed 100644
--- a/src/renderer/shell/shell.css
+++ b/src/renderer/shell/shell.css
@@ -132,7 +132,7 @@
.md-shell-header-actions button:focus-visible,
.md-shell-sidebar-toggle:focus-visible,
.md-avatar-menu-action:focus-visible {
- outline: 2px solid var(--ui-accent);
+ outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
@@ -165,7 +165,7 @@
border: 0;
border-radius: 6px;
background: var(--ui-primary);
- color: #0f0f0f;
+ color: var(--ui-primary-text);
cursor: pointer;
font-size: 14px;
font-weight: 600;
@@ -180,7 +180,7 @@
.md-update-trigger:focus-visible,
.md-update-dialog button:focus-visible,
.md-update-release-notes summary:focus-visible {
- outline: 2px solid var(--ui-accent);
+ outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
@@ -509,15 +509,19 @@
opacity: 0;
}
-.md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
- top: 12px;
+.md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
+ top: -48px;
right: auto;
left: 11px;
width: 32px;
height: 32px;
transform: none;
- opacity: 1;
-}
+ opacity: 1;
+}
+
+.md-shell.has-collapsed-sidebar:is(.is-compact, .is-minimum) .md-shell-navigation {
+ padding-left: 44px;
+}
.md-shell-sidebar:hover .md-shell-sidebar-toggle,
.md-shell-sidebar.is-collapsed .md-shell-sidebar-toggle,
@@ -755,7 +759,7 @@
.md-dialog :where(button, summary, input, select, textarea):focus-visible,
.md-context-menu [role="menuitem"]:focus-visible {
- outline: 2px solid var(--ui-accent);
+ outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
@@ -880,15 +884,19 @@
}
}
-@media (max-width: 1120px) {
+@media (max-width: 1120px) {
.md-shell.is-minimum {
gap: 6px;
padding: 6px 8px 8px;
}
- .md-shell.is-minimum .md-shell-workspace {
- gap: 6px;
- }
+ .md-shell.is-minimum .md-shell-workspace {
+ gap: 6px;
+ }
+
+ .md-shell.is-minimum .md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
+ top: -46px;
+ }
.md-dialog-backdrop,
.md-overlay-host .md-dialog-backdrop,
diff --git a/src/renderer/styles.css b/src/renderer/styles.css
index 714288b..b3d1104 100644
--- a/src/renderer/styles.css
+++ b/src/renderer/styles.css
@@ -3143,13 +3143,20 @@ td {
color: var(--muted);
}
-.link-popup-click {
- cursor: pointer;
- border-radius: 4px;
- padding: 1px 3px;
- margin: -1px -3px;
- transition: background 0.1s;
-}
+.link-popup-click {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ border-radius: 4px;
+ padding: 1px 3px;
+ margin: -1px -3px;
+ min-width: 0;
+ text-align: left;
+ transition: background 0.1s;
+}
.link-popup-click:hover {
background: var(--button-bg-hover);
diff --git a/src/renderer/theme.css b/src/renderer/theme.css
index d0a2e14..ab0e20a 100644
--- a/src/renderer/theme.css
+++ b/src/renderer/theme.css
@@ -7,20 +7,26 @@
--ui-active: #333436;
--ui-hover: #373535;
--ui-tooltip: #4F4D4D;
- --ui-border: #3D3D3D;
+ --ui-border: #3D3D3D;
+ --ui-control-border: #707070;
--ui-text: #FFFFFF;
--ui-text-secondary: #EAEDF3;
--ui-text-muted: #919191;
--ui-primary: #D6D6D6;
--ui-primary-hover: #E6E6E6;
+ --ui-primary-text: #181A1F;
--ui-accent: #4A4A4A;
- --ui-speed-accent: #F2942D;
+ --ui-focus: #9AB8E8;
+ --ui-speed-accent: #4ADE80;
--ui-progress-track-text: #FFFFFF;
--ui-progress-fill-text: #181A1F;
--ui-success: #4ADE80;
- --ui-warning: #F1C786;
- --ui-danger: #F06464;
- --ui-error-action-text: #181A1F;
+ --ui-success-text: #4ADE80;
+ --ui-warning: #F1C786;
+ --ui-warning-text: #F1C786;
+ --ui-danger: #F06464;
+ --ui-danger-text: #F06464;
+ --ui-error-action-text: var(--ui-primary-text);
--ui-modal-secondary: #35383D;
--ui-overlay: rgba(0, 0, 0, 0.60);
color-scheme: dark;
@@ -34,20 +40,26 @@
--ui-active: #DEE6F5;
--ui-hover: #E8ECF3;
--ui-tooltip: #35383D;
- --ui-border: #D0D4DB;
+ --ui-border: #D0D4DB;
+ --ui-control-border: #7B8491;
--ui-text: #181A1F;
--ui-text-secondary: #343842;
--ui-text-muted: #667085;
--ui-primary: #3A3A3A;
--ui-primary-hover: #202020;
+ --ui-primary-text: #FFFFFF;
--ui-accent: #5E5E5E;
- --ui-speed-accent: #C2701A;
+ --ui-focus: #24558D;
+ --ui-speed-accent: #1E9E55;
--ui-progress-track-text: #181A1F;
--ui-progress-fill-text: #181A1F;
--ui-success: #1E9E55;
- --ui-warning: #E8B85D;
- --ui-danger: #D94747;
- --ui-error-action-text: #181A1F;
+ --ui-success-text: #137A3D;
+ --ui-warning: #E8B85D;
+ --ui-warning-text: #7A4B00;
+ --ui-danger: #D94747;
+ --ui-danger-text: #B4232F;
+ --ui-error-action-text: var(--ui-primary-text);
--ui-modal-secondary: #E7E9ED;
--ui-overlay: rgba(0, 0, 0, 0.45);
color-scheme: light;
@@ -151,7 +163,7 @@ textarea,
summary,
[tabindex]:not([tabindex="-1"])
):focus-visible {
- outline: 2px solid var(--ui-accent);
+ outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
@@ -384,7 +396,7 @@ textarea,
.ui-error-boundary-details:focus-visible,
.ui-error-boundary-reload:focus-visible {
- outline: 2px solid var(--ui-accent);
+ outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx
index fcb76af..7ec5acd 100644
--- a/src/renderer/views/collector/CollectorView.tsx
+++ b/src/renderer/views/collector/CollectorView.tsx
@@ -77,8 +77,9 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
);
}
-export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
- return (
+export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
+ const activeTab = model.tabs.find((tab) => tab.id === model.activeTabId) ?? model.tabs[0];
+ return (
@@ -87,7 +88,7 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
-
+
(
- actions.onSelectionChange(row.id)}
type="checkbox"
diff --git a/src/renderer/views/collector/collector.css b/src/renderer/views/collector/collector.css
index 7237944..74d0c88 100644
--- a/src/renderer/views/collector/collector.css
+++ b/src/renderer/views/collector/collector.css
@@ -130,18 +130,18 @@
.collector-action-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
- color: #181A1F;
+ color: var(--ui-primary-text);
}
.collector-action-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
- color: #181A1F;
+ 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);
-}
+.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;
@@ -172,8 +172,8 @@
height: 41px;
}
-.collector-table-header-row {
- color: var(--ui-text-muted);
+.collector-table-header-row {
+ color: var(--ui-text-secondary);
font-size: 11px;
font-weight: 700;
height: 41px;
@@ -272,7 +272,7 @@
.collector-dialog-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
- color: #181A1F;
+ color: var(--ui-primary-text);
}
.collector-dialog-secondary {
@@ -300,8 +300,17 @@
}
}
-@media (max-width: 1120px) {
- .collector-table-header-row,
+@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;
diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx
index 10131af..b81aab9 100644
--- a/src/renderer/views/downloads/DownloadsTable.tsx
+++ b/src/renderer/views/downloads/DownloadsTable.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
+import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
import type { DownloadItem } from "../../../shared/types";
import {
compactProviderLabels,
@@ -47,16 +47,16 @@ function isPackageRowDisclosureExcluded(target: EventTarget | null): boolean {
}
export const downloadColumnDefinitions: Record = {
- name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
- size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
- progress: { label: "Fortschritt", width: "minmax(105px, 0.85fr)", sortable: "progress" },
- hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
- account: { label: "Service", width: "minmax(90px, 0.85fr)" },
- prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
+ name: { label: "Name", width: "minmax(var(--downloads-name-min, 290px), 2.3fr)", sortable: "name" },
+ size: { label: "Geladen / Größe", width: "minmax(var(--downloads-size-min, 140px), 1.1fr)", sortable: "size" },
+ progress: { label: "Fortschritt", width: "minmax(var(--downloads-progress-min, 105px), 0.85fr)", sortable: "progress" },
+ hoster: { label: "Hoster", width: "minmax(var(--downloads-hoster-min, 90px), 0.85fr)", sortable: "hoster" },
+ account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)" },
+ prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" },
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
- speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
- availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
- added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
+ speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" },
+ availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" },
+ added: { label: "Hinzugefügt am", width: "minmax(var(--downloads-added-min, 135px), 1fr)" }
};
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
@@ -103,14 +103,21 @@ export interface DownloadsTableActions {
onMovePackageDown: (packageId: string) => void;
onRemoveItem: (itemId: string) => void;
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
- onColumnPointerDown: (column: string, event: ReactPointerEvent) => void;
- onColumnPointerMove: (column: string, event: ReactPointerEvent) => void;
- onColumnPointerUp: (column: string, event: ReactPointerEvent) => void;
- onColumnPointerCancel: (column: string, event: ReactPointerEvent) => void;
+ onColumnPointerDown: (column: string, event: DownloadColumnPointerInput) => void;
+ onColumnPointerMove: (column: string, event: DownloadColumnPointerInput) => void;
+ onColumnPointerUp: (column: string, event: DownloadColumnPointerInput) => void;
+ onColumnPointerCancel: (column: string, event: DownloadColumnPointerInput) => void;
onColumnContextMenu: (column: string, x: number, y: number) => void;
onSortColumn: (column: DownloadSortColumn) => void;
}
+export interface DownloadColumnPointerInput {
+ clientX: number;
+ currentTarget: HTMLDivElement;
+ pointerId: number;
+ preventDefault: () => void;
+}
+
function displayedStatus(item: DownloadItem, sessionRunning: boolean): string {
const value = item.fullStatus.trim();
if (value === "Wartet") return "";
@@ -548,15 +555,33 @@ export interface DownloadsTableHeaderProps {
visibleIds: string[];
}
+function moveColumnWithPointerActions(column: string, direction: -1 | 1, element: HTMLDivElement, actions: DownloadsTableActions): void {
+ const sibling = direction < 0 ? element.previousElementSibling : element.nextElementSibling;
+ if (!sibling?.matches(".downloads-column-header")) return;
+ const currentRect = element.getBoundingClientRect();
+ const siblingRect = sibling.getBoundingClientRect();
+ const startX = currentRect.left + currentRect.width / 2;
+ const clientX = siblingRect.left + siblingRect.width / 2 + direction;
+ const pointerId = -1;
+ const pointerEvent = (x: number): DownloadColumnPointerInput => ({ clientX: x, currentTarget: element, pointerId, preventDefault: () => {} });
+ actions.onColumnPointerDown(column, pointerEvent(startX));
+ actions.onColumnPointerMove(column, pointerEvent(clientX));
+ actions.onColumnPointerUp(column, pointerEvent(clientX));
+}
+
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
+ const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length;
+ const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length;
return (
-
0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" />
- {columnOrder.map((column) => {
+
actions.onSetVisibleSelection(visibleIds, event.target.checked)} ref={(input) => { if (input) input.indeterminate = mixedSelection; }} type="checkbox" />
+ {columnOrder.map((column, index) => {
const definition = downloadColumnDefinitions[column];
if (!definition) return null;
+ const ariaSort = definition.sortable ? sortColumn === definition.sortable ? sortDirection === "asc" ? "ascending" : "descending" : "none" : undefined;
return (
{definition.sortable
- ?
- : definition.label}
+ ?
+ : {definition.label}}
+ event.stopPropagation()} role="group">
+ {index > 0 ? : null}
+ {index < columnOrder.length - 1 ? : null}
+
);
})}
diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx
index fc21939..ea10a45 100644
--- a/src/renderer/views/downloads/DownloadsView.tsx
+++ b/src/renderer/views/downloads/DownloadsView.tsx
@@ -86,7 +86,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct