release: publish v2.0.21 responsive accessibility audit
Refine all primary views across supported desktop widths, preserve compact download actions, strengthen keyboard and assistive semantics, confirm irreversible actions, complete new bilingual UI strings, and unify live speed visualization colors.
This commit is contained in:
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
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
|
## [2.0.20] - 2026-08-11
|
||||||
|
|
||||||
### Downloads and selection
|
### Downloads and selection
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.20",
|
"version": "2.0.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.20",
|
"version": "2.0.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.20",
|
"version": "2.0.21",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
+120
-55
@@ -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(
|
export function appendBandwidthSample(
|
||||||
history: { time: number; speed: number }[],
|
history: { time: number; speed: number }[],
|
||||||
speed: number,
|
speed: number,
|
||||||
@@ -1462,14 +1470,15 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}, [running, paused]);
|
}, [running, paused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
drawChart();
|
drawChart();
|
||||||
if (!running || paused) {
|
if (!running || paused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const interval = setInterval(() => {
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
drawChart();
|
const interval = setInterval(() => {
|
||||||
}, 250);
|
drawChart();
|
||||||
|
}, reducedMotion ? 1000 : 250);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [drawChart, running, paused]);
|
}, [drawChart, running, paused]);
|
||||||
|
|
||||||
@@ -1492,11 +1501,13 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
|
|||||||
drawChart();
|
drawChart();
|
||||||
}, [drawChart, paused]);
|
}, [drawChart, paused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="bandwidth-chart-container">
|
<div ref={containerRef} className="bandwidth-chart-container">
|
||||||
<canvas ref={canvasRef} />
|
<canvas aria-label="Bandbreitenverlauf der letzten 60 Sekunden" ref={canvasRef} role="img">
|
||||||
</div>
|
Bandbreitenverlauf der letzten 60 Sekunden
|
||||||
);
|
</canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface DownloadSpeedSparklineProps {
|
interface DownloadSpeedSparklineProps {
|
||||||
@@ -1528,9 +1539,8 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
|
|||||||
const hist = speedStateRef.current.history;
|
const hist = speedStateRef.current.history;
|
||||||
if (hist.length < 2) return;
|
if (hist.length < 2) return;
|
||||||
|
|
||||||
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
const accent = isDark ? "#f2942d" : "#c2701a";
|
const palette = readDownloadSpeedSparklinePalette((property) => rootStyle.getPropertyValue(property));
|
||||||
const fill = isDark ? "rgba(242, 148, 45, 0.16)" : "rgba(194, 112, 26, 0.16)";
|
|
||||||
|
|
||||||
let maxV = 0;
|
let maxV = 0;
|
||||||
for (const v of hist) if (v > maxV) maxV = v;
|
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(hist.length - 1), pad + h);
|
||||||
ctx.lineTo(px(0), pad + h);
|
ctx.lineTo(px(0), pad + h);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fillStyle = fill;
|
ctx.save();
|
||||||
ctx.fill();
|
ctx.globalAlpha = 0.16;
|
||||||
|
ctx.fillStyle = palette.accent;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(px(0), py(hist[0]));
|
ctx.moveTo(px(0), py(hist[0]));
|
||||||
for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i]));
|
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.lineWidth = 1.5;
|
||||||
ctx.lineJoin = "round";
|
ctx.lineJoin = "round";
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
@@ -3883,20 +3896,37 @@ export function App(): ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeCollectorTab = (id: string): void => {
|
const removeCollectorTab = (id: string): void => {
|
||||||
const removal = planCollectorTabRemoval(
|
const tab = collectorTabsRef.current.find((entry) => entry.id === id);
|
||||||
collectorTabsRef.current,
|
if (!tab || collectorTabsRef.current.length <= 1) {
|
||||||
activeCollectorTabRef.current,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
if (removal.tabs === collectorTabsRef.current) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
collectorTabsRef.current = removal.tabs;
|
const linkCount = tab.text.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
||||||
activeCollectorTabRef.current = removal.activeTabId;
|
void askConfirmPrompt({
|
||||||
setCollectorTabs(removal.tabs);
|
title: "Sammlung entfernen",
|
||||||
setActiveCollectorTab(removal.activeTabId);
|
message: linkCount > 0
|
||||||
setSelectedCollectorRowIds(new Set());
|
? `Soll die Sammlung ${tab.name} mit ${linkCount} Link(s) wirklich entfernt werden?`
|
||||||
setCollectorError("");
|
: `Soll die leere Sammlung ${tab.name} wirklich entfernt werden?`,
|
||||||
|
confirmLabel: "Sammlung entfernen",
|
||||||
|
danger: true
|
||||||
|
}).then((confirmed) => {
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removal = planCollectorTabRemoval(
|
||||||
|
collectorTabsRef.current,
|
||||||
|
activeCollectorTabRef.current,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (removal.tabs === collectorTabsRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collectorTabsRef.current = removal.tabs;
|
||||||
|
activeCollectorTabRef.current = removal.activeTabId;
|
||||||
|
setCollectorTabs(removal.tabs);
|
||||||
|
setActiveCollectorTab(removal.activeTabId);
|
||||||
|
setSelectedCollectorRowIds(new Set());
|
||||||
|
setCollectorError("");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCollectorInput = (): void => {
|
const openCollectorInput = (): void => {
|
||||||
@@ -3957,11 +3987,24 @@ export function App(): ReactElement {
|
|||||||
indexes.add(index);
|
indexes.add(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCollectorTabs((prev) => prev.map((entry) => entry.id === activeId
|
if (indexes.size === 0) {
|
||||||
? { ...entry, text: entry.text.split(/\r?\n/).filter((_line, index) => !indexes.has(index)).join("\n") }
|
return;
|
||||||
: entry));
|
}
|
||||||
setSelectedCollectorRowIds(new Set());
|
void askConfirmPrompt({
|
||||||
setCollectorError("");
|
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 => {
|
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
|
||||||
@@ -5067,17 +5110,37 @@ export function App(): ReactElement {
|
|||||||
const statisticsActions: StatisticsViewActions = {
|
const statisticsActions: StatisticsViewActions = {
|
||||||
onRangeChange: setStatisticsRange,
|
onRangeChange: setStatisticsRange,
|
||||||
onResetSession: () => {
|
onResetSession: () => {
|
||||||
void window.rd.resetSessionStats().then(() => {
|
void askConfirmPrompt({
|
||||||
showToast("Session-Statistik zurückgesetzt", 1800);
|
title: "Sitzungsstatistik zurücksetzen",
|
||||||
}).catch((error) => {
|
message: "Die Zähler, Downloadmenge und Geschwindigkeitsdaten der aktuellen Sitzung werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.",
|
||||||
showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400);
|
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: () => {
|
onResetAll: () => {
|
||||||
void window.rd.resetDownloadStats().then(() => {
|
void askConfirmPrompt({
|
||||||
showToast("Gesamt-Downloadstatistik zurückgesetzt", 1800);
|
title: "Gesamtstatistik zurücksetzen",
|
||||||
}).catch((error) => {
|
message: "Alle dauerhaft gespeicherten Download- und Providerstatistiken werden gelöscht. Dieser Schritt kann nicht rückgängig gemacht werden.",
|
||||||
showToast(`Download-Reset fehlgeschlagen: ${String(error)}`, 2400);
|
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: () => {
|
onResetErrors: () => {
|
||||||
@@ -6457,17 +6520,19 @@ export function App(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="col-key">{ki + 1}</span>
|
<span className="col-key">{ki + 1}</span>
|
||||||
<span
|
<button
|
||||||
className="col-masked link-popup-click"
|
aria-label={`${key.label} kopieren`}
|
||||||
title={`${key.masked}\nKlicken zum Kopieren`}
|
className="col-masked link-popup-click"
|
||||||
onClick={() => {
|
type="button"
|
||||||
|
title={`${key.masked}\nKlicken zum Kopieren`}
|
||||||
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(key.token)
|
void navigator.clipboard.writeText(key.token)
|
||||||
.then(() => showToast(`${key.label} kopiert`, 1800))
|
.then(() => showToast(`${key.label} kopiert`, 1800))
|
||||||
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
|
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{key.masked}
|
{key.masked}
|
||||||
</span>
|
</button>
|
||||||
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
|
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
|
||||||
<span className="col-limit">{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
|
<span className="col-limit">{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
|
||||||
<span className={`col-status status-pill status-pill-${statusDisplay.tone}`} title={statusDisplay.title}>{statusDisplay.label}</span>
|
<span className={`col-status status-pill status-pill-${statusDisplay.tone}`} title={statusDisplay.title}>{statusDisplay.label}</span>
|
||||||
@@ -6519,8 +6584,8 @@ export function App(): ReactElement {
|
|||||||
<div className="link-popup-list">
|
<div className="link-popup-list">
|
||||||
{linkPopup.links.map((link, i) => (
|
{linkPopup.links.map((link, i) => (
|
||||||
<div key={i} className="link-popup-row">
|
<div key={i} className="link-popup-row">
|
||||||
<span className="link-popup-name link-popup-click" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</span>
|
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button>
|
||||||
<span className="link-popup-url link-popup-click" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</span>
|
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+37
-10
@@ -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"],
|
["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"],
|
["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"],
|
["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."],
|
["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"],
|
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
|
||||||
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
|
["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"],
|
["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"],
|
["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"],
|
["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"],
|
["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"],
|
["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"],
|
["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"],
|
["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."],
|
["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"],
|
["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"],
|
["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."],
|
["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."],
|
["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"],
|
["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"],
|
["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."],
|
["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"],
|
["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"],
|
["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."],
|
["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"],
|
["Oberfläche neu laden", "Reload interface"], ["Unbekannter Fehler", "Unknown error"],
|
||||||
@@ -216,7 +217,7 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
|||||||
if (language === "en") {
|
if (language === "en") {
|
||||||
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
|
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
|
||||||
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
|
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]}`;
|
if (pagination) return `${pagination[1]} of ${pagination[2]}`;
|
||||||
const schedule = value.match(/^Zeitregel (\d+)$/);
|
const schedule = value.match(/^Zeitregel (\d+)$/);
|
||||||
if (schedule) return `Schedule rule ${schedule[1]}`;
|
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]}`;
|
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$/);
|
const perPage = value.match(/^(\d+) pro Seite$/);
|
||||||
if (perPage) return `${perPage[1]} per page`;
|
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+)$/);
|
const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/);
|
||||||
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
|
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
|
||||||
const remaining = value.match(/^(.+) von (.+) übrig$/);
|
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]})`;
|
if (warnings) return `${warnings[1]} errors, ${warnings[2]} warnings (latest ${warnings[3]})`;
|
||||||
const capture = value.match(/^Links für (.+) lokal erfassen\.$/);
|
const capture = value.match(/^Links für (.+) lokal erfassen\.$/);
|
||||||
if (capture) return `Capture links locally for ${capture[1]}.`;
|
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]}`;
|
if (copy) return `Click to copy ${copy[1]}`;
|
||||||
const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
|
const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
|
||||||
if (cancelledPart) return `· ${cancelledPart[1]} cancelled`;
|
if (cancelledPart) return `· ${cancelledPart[1]} cancelled`;
|
||||||
@@ -340,11 +345,21 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
|||||||
if (toggleAll) return `Toggle all ${toggleAll[1]}`;
|
if (toggleAll) return `Toggle all ${toggleAll[1]}`;
|
||||||
const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/);
|
const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/);
|
||||||
if (removeSelected) return `Remove selected (${removeSelected[1]})`;
|
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$/);
|
const copied = value.match(/^(.+) kopiert$/);
|
||||||
if (copied) return `${copied[1]} copied`;
|
if (copied) return `${copied[1]} copied`;
|
||||||
const suffixes: Array<[RegExp, string]> = [
|
const suffixes: Array<[RegExp, string]> = [
|
||||||
[/^(.+) auswählen$/, "Select $1"], [/^(.+) einklappen$/, "Collapse $1"], [/^(.+) ausklappen$/, "Expand $1"],
|
[/^(.+) 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);
|
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||||
return value
|
return value
|
||||||
@@ -355,7 +370,7 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
|||||||
} else {
|
} else {
|
||||||
const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/);
|
const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/);
|
||||||
if (update) return `${update[1]} ist verfügbar. Installierte Version: ${update[2]}.`;
|
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]}`;
|
if (pagination) return `${pagination[1]} von ${pagination[2]}`;
|
||||||
const schedule = value.match(/^Schedule rule (\d+)$/);
|
const schedule = value.match(/^Schedule rule (\d+)$/);
|
||||||
if (schedule) return `Zeitregel ${schedule[1]}`;
|
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?`;
|
if (historyQuestion) return `${historyQuestion[1]} Einträge aus dem Verlauf entfernen?`;
|
||||||
const historyRemoved = value.match(/^(\d+) history entries removed$/);
|
const historyRemoved = value.match(/^(\d+) history entries removed$/);
|
||||||
if (historyRemoved) return `${historyRemoved[1]} Verlaufseinträge entfernt`;
|
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\)$/);
|
const packageCount = value.match(/^(\d+) package\(s\)$/);
|
||||||
if (packageCount) return `${packageCount[1]} Paket(e)`;
|
if (packageCount) return `${packageCount[1]} Paket(e)`;
|
||||||
const linkCount = value.match(/^(\d+) link\(s\)$/);
|
const linkCount = value.match(/^(\d+) link\(s\)$/);
|
||||||
@@ -483,7 +510,7 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
|||||||
if (copied) return `${copied[1]} kopiert`;
|
if (copied) return `${copied[1]} kopiert`;
|
||||||
const suffixes: Array<[RegExp, string]> = [
|
const suffixes: Array<[RegExp, string]> = [
|
||||||
[/^Select (.+)$/, "$1 auswählen"], [/^Collapse (.+)$/, "$1 einklappen"], [/^Expand (.+)$/, "$1 ausklappen"],
|
[/^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);
|
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
.md-shell-header-actions button:focus-visible,
|
.md-shell-header-actions button:focus-visible,
|
||||||
.md-shell-sidebar-toggle:focus-visible,
|
.md-shell-sidebar-toggle:focus-visible,
|
||||||
.md-avatar-menu-action:focus-visible {
|
.md-avatar-menu-action:focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--ui-primary);
|
background: var(--ui-primary);
|
||||||
color: #0f0f0f;
|
color: var(--ui-primary-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
.md-update-trigger:focus-visible,
|
.md-update-trigger:focus-visible,
|
||||||
.md-update-dialog button:focus-visible,
|
.md-update-dialog button:focus-visible,
|
||||||
.md-update-release-notes summary:focus-visible {
|
.md-update-release-notes summary:focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,15 +509,19 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
|
.md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
|
||||||
top: 12px;
|
top: -48px;
|
||||||
right: auto;
|
right: auto;
|
||||||
left: 11px;
|
left: 11px;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
transform: none;
|
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:hover .md-shell-sidebar-toggle,
|
||||||
.md-shell-sidebar.is-collapsed .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-dialog :where(button, summary, input, select, textarea):focus-visible,
|
||||||
.md-context-menu [role="menuitem"]:focus-visible {
|
.md-context-menu [role="menuitem"]:focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,15 +884,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.md-shell.is-minimum {
|
.md-shell.is-minimum {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 8px 8px;
|
padding: 6px 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-shell.is-minimum .md-shell-workspace {
|
.md-shell.is-minimum .md-shell-workspace {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-shell.is-minimum .md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
|
||||||
|
top: -46px;
|
||||||
|
}
|
||||||
|
|
||||||
.md-dialog-backdrop,
|
.md-dialog-backdrop,
|
||||||
.md-overlay-host .md-dialog-backdrop,
|
.md-overlay-host .md-dialog-backdrop,
|
||||||
|
|||||||
+14
-7
@@ -3143,13 +3143,20 @@ td {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-popup-click {
|
.link-popup-click {
|
||||||
cursor: pointer;
|
appearance: none;
|
||||||
border-radius: 4px;
|
border: 0;
|
||||||
padding: 1px 3px;
|
background: transparent;
|
||||||
margin: -1px -3px;
|
color: inherit;
|
||||||
transition: background 0.1s;
|
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 {
|
.link-popup-click:hover {
|
||||||
background: var(--button-bg-hover);
|
background: var(--button-bg-hover);
|
||||||
|
|||||||
+24
-12
@@ -7,20 +7,26 @@
|
|||||||
--ui-active: #333436;
|
--ui-active: #333436;
|
||||||
--ui-hover: #373535;
|
--ui-hover: #373535;
|
||||||
--ui-tooltip: #4F4D4D;
|
--ui-tooltip: #4F4D4D;
|
||||||
--ui-border: #3D3D3D;
|
--ui-border: #3D3D3D;
|
||||||
|
--ui-control-border: #707070;
|
||||||
--ui-text: #FFFFFF;
|
--ui-text: #FFFFFF;
|
||||||
--ui-text-secondary: #EAEDF3;
|
--ui-text-secondary: #EAEDF3;
|
||||||
--ui-text-muted: #919191;
|
--ui-text-muted: #919191;
|
||||||
--ui-primary: #D6D6D6;
|
--ui-primary: #D6D6D6;
|
||||||
--ui-primary-hover: #E6E6E6;
|
--ui-primary-hover: #E6E6E6;
|
||||||
|
--ui-primary-text: #181A1F;
|
||||||
--ui-accent: #4A4A4A;
|
--ui-accent: #4A4A4A;
|
||||||
--ui-speed-accent: #F2942D;
|
--ui-focus: #9AB8E8;
|
||||||
|
--ui-speed-accent: #4ADE80;
|
||||||
--ui-progress-track-text: #FFFFFF;
|
--ui-progress-track-text: #FFFFFF;
|
||||||
--ui-progress-fill-text: #181A1F;
|
--ui-progress-fill-text: #181A1F;
|
||||||
--ui-success: #4ADE80;
|
--ui-success: #4ADE80;
|
||||||
--ui-warning: #F1C786;
|
--ui-success-text: #4ADE80;
|
||||||
--ui-danger: #F06464;
|
--ui-warning: #F1C786;
|
||||||
--ui-error-action-text: #181A1F;
|
--ui-warning-text: #F1C786;
|
||||||
|
--ui-danger: #F06464;
|
||||||
|
--ui-danger-text: #F06464;
|
||||||
|
--ui-error-action-text: var(--ui-primary-text);
|
||||||
--ui-modal-secondary: #35383D;
|
--ui-modal-secondary: #35383D;
|
||||||
--ui-overlay: rgba(0, 0, 0, 0.60);
|
--ui-overlay: rgba(0, 0, 0, 0.60);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -34,20 +40,26 @@
|
|||||||
--ui-active: #DEE6F5;
|
--ui-active: #DEE6F5;
|
||||||
--ui-hover: #E8ECF3;
|
--ui-hover: #E8ECF3;
|
||||||
--ui-tooltip: #35383D;
|
--ui-tooltip: #35383D;
|
||||||
--ui-border: #D0D4DB;
|
--ui-border: #D0D4DB;
|
||||||
|
--ui-control-border: #7B8491;
|
||||||
--ui-text: #181A1F;
|
--ui-text: #181A1F;
|
||||||
--ui-text-secondary: #343842;
|
--ui-text-secondary: #343842;
|
||||||
--ui-text-muted: #667085;
|
--ui-text-muted: #667085;
|
||||||
--ui-primary: #3A3A3A;
|
--ui-primary: #3A3A3A;
|
||||||
--ui-primary-hover: #202020;
|
--ui-primary-hover: #202020;
|
||||||
|
--ui-primary-text: #FFFFFF;
|
||||||
--ui-accent: #5E5E5E;
|
--ui-accent: #5E5E5E;
|
||||||
--ui-speed-accent: #C2701A;
|
--ui-focus: #24558D;
|
||||||
|
--ui-speed-accent: #1E9E55;
|
||||||
--ui-progress-track-text: #181A1F;
|
--ui-progress-track-text: #181A1F;
|
||||||
--ui-progress-fill-text: #181A1F;
|
--ui-progress-fill-text: #181A1F;
|
||||||
--ui-success: #1E9E55;
|
--ui-success: #1E9E55;
|
||||||
--ui-warning: #E8B85D;
|
--ui-success-text: #137A3D;
|
||||||
--ui-danger: #D94747;
|
--ui-warning: #E8B85D;
|
||||||
--ui-error-action-text: #181A1F;
|
--ui-warning-text: #7A4B00;
|
||||||
|
--ui-danger: #D94747;
|
||||||
|
--ui-danger-text: #B4232F;
|
||||||
|
--ui-error-action-text: var(--ui-primary-text);
|
||||||
--ui-modal-secondary: #E7E9ED;
|
--ui-modal-secondary: #E7E9ED;
|
||||||
--ui-overlay: rgba(0, 0, 0, 0.45);
|
--ui-overlay: rgba(0, 0, 0, 0.45);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -151,7 +163,7 @@ textarea,
|
|||||||
summary,
|
summary,
|
||||||
[tabindex]:not([tabindex="-1"])
|
[tabindex]:not([tabindex="-1"])
|
||||||
):focus-visible {
|
):focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +396,7 @@ textarea,
|
|||||||
|
|
||||||
.ui-error-boundary-details:focus-visible,
|
.ui-error-boundary-details:focus-visible,
|
||||||
.ui-error-boundary-reload:focus-visible {
|
.ui-error-boundary-reload:focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
||||||
return (
|
const activeTab = model.tabs.find((tab) => tab.id === model.activeTabId) ?? model.tabs[0];
|
||||||
|
return (
|
||||||
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
||||||
<ToolbarGroup label="Links erfassen">
|
<ToolbarGroup label="Links erfassen">
|
||||||
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||||
@@ -87,7 +88,7 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
|
|||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
<ToolbarGroup label="Sammlung verarbeiten">
|
<ToolbarGroup label="Sammlung verarbeiten">
|
||||||
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
|
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
|
||||||
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
<button className="collector-action" disabled={model.busy || !activeTab || activeTab.linkCount === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
||||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
<ToolbarSearch
|
<ToolbarSearch
|
||||||
@@ -129,8 +130,8 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
|
|||||||
model.rows.map((row) => (
|
model.rows.map((row) => (
|
||||||
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
|
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
|
||||||
<span className="collector-column-select" role="cell">
|
<span className="collector-column-select" role="cell">
|
||||||
<input
|
<input
|
||||||
aria-label="Link auswählen"
|
aria-label={`${row.value} aus ${row.tabName}, Zeile ${row.lineNumber} auswählen`}
|
||||||
checked={selected.has(row.id)}
|
checked={selected.has(row.id)}
|
||||||
onChange={() => actions.onSelectionChange(row.id)}
|
onChange={() => actions.onSelectionChange(row.id)}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -130,18 +130,18 @@
|
|||||||
.collector-action-primary {
|
.collector-action-primary {
|
||||||
background: var(--ui-primary);
|
background: var(--ui-primary);
|
||||||
border-color: var(--ui-primary);
|
border-color: var(--ui-primary);
|
||||||
color: #181A1F;
|
color: var(--ui-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-action-primary:hover:not(:disabled) {
|
.collector-action-primary:hover:not(:disabled) {
|
||||||
background: var(--ui-primary-hover);
|
background: var(--ui-primary-hover);
|
||||||
color: #181A1F;
|
color: var(--ui-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-action-danger:not(:disabled) {
|
.collector-action-danger:not(:disabled) {
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-action:disabled {
|
.collector-action:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -172,8 +172,8 @@
|
|||||||
height: 41px;
|
height: 41px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-table-header-row {
|
.collector-table-header-row {
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-secondary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
height: 41px;
|
height: 41px;
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
.collector-dialog-primary {
|
.collector-dialog-primary {
|
||||||
background: var(--ui-primary);
|
background: var(--ui-primary);
|
||||||
border-color: var(--ui-primary);
|
border-color: var(--ui-primary);
|
||||||
color: #181A1F;
|
color: var(--ui-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collector-dialog-secondary {
|
.collector-dialog-secondary {
|
||||||
@@ -300,8 +300,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.collector-table-header-row,
|
.collector-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-toolbar .ui-toolbar-search {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collector-table-header-row,
|
||||||
.collector-row {
|
.collector-row {
|
||||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
||||||
min-width: 610px;
|
min-width: 610px;
|
||||||
|
|||||||
@@ -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 type { DownloadItem } from "../../../shared/types";
|
||||||
import {
|
import {
|
||||||
compactProviderLabels,
|
compactProviderLabels,
|
||||||
@@ -47,16 +47,16 @@ function isPackageRowDisclosureExcluded(target: EventTarget | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
|
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
|
||||||
name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
|
name: { label: "Name", width: "minmax(var(--downloads-name-min, 290px), 2.3fr)", sortable: "name" },
|
||||||
size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
|
size: { label: "Geladen / Größe", width: "minmax(var(--downloads-size-min, 140px), 1.1fr)", sortable: "size" },
|
||||||
progress: { label: "Fortschritt", width: "minmax(105px, 0.85fr)", sortable: "progress" },
|
progress: { label: "Fortschritt", width: "minmax(var(--downloads-progress-min, 105px), 0.85fr)", sortable: "progress" },
|
||||||
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
|
hoster: { label: "Hoster", width: "minmax(var(--downloads-hoster-min, 90px), 0.85fr)", sortable: "hoster" },
|
||||||
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
|
account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)" },
|
||||||
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
|
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)" },
|
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
|
||||||
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
|
speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" },
|
||||||
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
|
availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" },
|
||||||
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
|
added: { label: "Hinzugefügt am", width: "minmax(var(--downloads-added-min, 135px), 1fr)" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
|
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
|
||||||
@@ -103,14 +103,21 @@ export interface DownloadsTableActions {
|
|||||||
onMovePackageDown: (packageId: string) => void;
|
onMovePackageDown: (packageId: string) => void;
|
||||||
onRemoveItem: (itemId: string) => void;
|
onRemoveItem: (itemId: string) => void;
|
||||||
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
|
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
|
||||||
onColumnPointerDown: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
onColumnPointerDown: (column: string, event: DownloadColumnPointerInput) => void;
|
||||||
onColumnPointerMove: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
onColumnPointerMove: (column: string, event: DownloadColumnPointerInput) => void;
|
||||||
onColumnPointerUp: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
onColumnPointerUp: (column: string, event: DownloadColumnPointerInput) => void;
|
||||||
onColumnPointerCancel: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
onColumnPointerCancel: (column: string, event: DownloadColumnPointerInput) => void;
|
||||||
onColumnContextMenu: (column: string, x: number, y: number) => void;
|
onColumnContextMenu: (column: string, x: number, y: number) => void;
|
||||||
onSortColumn: (column: DownloadSortColumn) => void;
|
onSortColumn: (column: DownloadSortColumn) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DownloadColumnPointerInput {
|
||||||
|
clientX: number;
|
||||||
|
currentTarget: HTMLDivElement;
|
||||||
|
pointerId: number;
|
||||||
|
preventDefault: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
function displayedStatus(item: DownloadItem, sessionRunning: boolean): string {
|
function displayedStatus(item: DownloadItem, sessionRunning: boolean): string {
|
||||||
const value = item.fullStatus.trim();
|
const value = item.fullStatus.trim();
|
||||||
if (value === "Wartet") return "";
|
if (value === "Wartet") return "";
|
||||||
@@ -548,15 +555,33 @@ export interface DownloadsTableHeaderProps {
|
|||||||
visibleIds: string[];
|
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 {
|
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 (
|
return (
|
||||||
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
|
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
|
||||||
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
|
<span className="downloads-selection-cell" role="columnheader"><input aria-checked={mixedSelection ? "mixed" : allSelected} aria-label="Alle sichtbaren Downloads auswählen" checked={allSelected} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} ref={(input) => { if (input) input.indeterminate = mixedSelection; }} type="checkbox" /></span>
|
||||||
{columnOrder.map((column) => {
|
{columnOrder.map((column, index) => {
|
||||||
const definition = downloadColumnDefinitions[column];
|
const definition = downloadColumnDefinitions[column];
|
||||||
if (!definition) return null;
|
if (!definition) return null;
|
||||||
|
const ariaSort = definition.sortable ? sortColumn === definition.sortable ? sortDirection === "asc" ? "ascending" : "descending" : "none" : undefined;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
aria-sort={ariaSort}
|
||||||
className="downloads-column-header"
|
className="downloads-column-header"
|
||||||
data-download-column={column}
|
data-download-column={column}
|
||||||
key={column}
|
key={column}
|
||||||
@@ -575,8 +600,12 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
|
|||||||
role="columnheader"
|
role="columnheader"
|
||||||
>
|
>
|
||||||
{definition.sortable
|
{definition.sortable
|
||||||
? <button onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
|
? <button className="downloads-column-sort" onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
|
||||||
: definition.label}
|
: <span className="downloads-column-label">{definition.label}</span>}
|
||||||
|
<span aria-label={`${definition.label} verschieben`} className="downloads-column-move-controls" onPointerDown={(event) => event.stopPropagation()} role="group">
|
||||||
|
{index > 0 ? <button aria-label={`${definition.label} nach links verschieben`} onClick={(event) => { event.stopPropagation(); const element = event.currentTarget.closest<HTMLDivElement>(".downloads-column-header"); if (element) moveColumnWithPointerActions(column, -1, element, actions); }} type="button">←</button> : null}
|
||||||
|
{index < columnOrder.length - 1 ? <button aria-label={`${definition.label} nach rechts verschieben`} onClick={(event) => { event.stopPropagation(); const element = event.currentTarget.closest<HTMLDivElement>(".downloads-column-header"); if (element) moveColumnWithPointerActions(column, 1, element, actions); }} type="button">→</button> : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||||
<div className="downloads-mode-title">Pakete</div>
|
<div className="downloads-mode-title">Pakete</div>
|
||||||
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
|
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
|
||||||
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||||
</SlidingSelection>
|
</SlidingSelection>
|
||||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
.downloads-footer button,
|
.downloads-footer button,
|
||||||
.downloads-action-cell button,
|
.downloads-action-cell button,
|
||||||
.downloads-collapse-button,
|
.downloads-collapse-button,
|
||||||
.downloads-column-header button {
|
.downloads-column-sort {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-border);
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
.downloads-table-body {
|
.downloads-table-body {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1191px;
|
min-width: var(--downloads-table-min-width, 1191px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-table-header {
|
.downloads-table-header {
|
||||||
@@ -419,6 +419,11 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-cell-slot > :is(.downloads-status-cell, .downloads-service-cell) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-cell,
|
.downloads-cell,
|
||||||
.downloads-name-cell {
|
.downloads-name-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -508,6 +513,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-column-header {
|
.downloads-column-header {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -565,7 +571,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-column-header button {
|
.downloads-column-sort {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -579,10 +585,50 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-column-header[data-download-column="name"] button {
|
.downloads-column-header[data-download-column="name"] .downloads-column-sort {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-column-move-controls {
|
||||||
|
position: absolute;
|
||||||
|
inset: 50% 2px auto auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--ui-table-header);
|
||||||
|
box-shadow: 0 1px 4px color-mix(in srgb, var(--ui-text) 18%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-column-header:hover .downloads-column-move-controls,
|
||||||
|
.downloads-column-header:focus-within .downloads-column-move-controls {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-column-move-controls button {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 22px;
|
||||||
|
height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ui-text);
|
||||||
|
background: var(--ui-modal-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-column-move-controls button:hover,
|
||||||
|
.downloads-column-move-controls button:focus-visible {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-link-state {
|
.downloads-link-state {
|
||||||
flex: 0 0 8px;
|
flex: 0 0 8px;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -642,15 +688,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-availability.is-online {
|
.downloads-availability.is-online {
|
||||||
color: var(--ui-success);
|
color: var(--ui-success-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-availability.is-partial {
|
.downloads-availability.is-partial {
|
||||||
color: var(--ui-warning);
|
color: var(--ui-warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-availability.is-offline {
|
.downloads-availability.is-offline {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-availability.is-checking {
|
.downloads-availability.is-checking {
|
||||||
@@ -712,14 +758,33 @@
|
|||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:is(.downloads-status-full, .downloads-status-compact, .downloads-service-full, .downloads-service-compact) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-status-compact,
|
.downloads-status-compact,
|
||||||
.downloads-service-compact {
|
.downloads-service-compact {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-shell.is-compact .downloads-view,
|
.md-shell.is-compact .downloads-content,
|
||||||
.md-shell.is-minimum .downloads-view {
|
.md-shell.is-minimum .downloads-content {
|
||||||
|
--downloads-table-min-width: 1016px;
|
||||||
|
--downloads-name-min: 180px;
|
||||||
|
--downloads-size-min: 96px;
|
||||||
|
--downloads-progress-min: 78px;
|
||||||
|
--downloads-hoster-min: 62px;
|
||||||
|
--downloads-service-min: 78px;
|
||||||
|
--downloads-priority-min: 65px;
|
||||||
--downloads-status-min: 90px;
|
--downloads-status-min: 90px;
|
||||||
|
--downloads-speed-min: 84px;
|
||||||
|
--downloads-availability-min: 82px;
|
||||||
|
--downloads-added-min: 105px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 150px) {
|
@container (max-width: 150px) {
|
||||||
@@ -728,7 +793,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-status-compact {
|
.downloads-status-compact {
|
||||||
display: inline;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-service-full {
|
.downloads-service-full {
|
||||||
@@ -736,7 +801,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-service-compact {
|
.downloads-service-compact {
|
||||||
display: inline;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
|
import { useEffect, useState, type ChangeEvent, type MouseEvent, type ReactElement } from "react";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableBody,
|
DataTableBody,
|
||||||
DataTableEmpty,
|
DataTableEmpty,
|
||||||
DataTableFooter,
|
|
||||||
DataTableHeader
|
DataTableHeader
|
||||||
} from "../../ui/DataTable";
|
} from "../../ui/DataTable";
|
||||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
|
import { paginateHistoryRows, type HistoryFilter, type HistoryPage, type HistoryRow, type HistoryViewModel } from "./history-model";
|
||||||
import "./history.css";
|
import "./history.css";
|
||||||
|
|
||||||
export interface HistoryViewActions {
|
export interface HistoryViewActions {
|
||||||
@@ -109,18 +108,65 @@ export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactEleme
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
export function historyPageStatusLabel(page: HistoryPage): string {
|
||||||
|
return `Seite ${page.page} von ${page.totalPages}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPagination({
|
||||||
|
page,
|
||||||
|
onPageChange
|
||||||
|
}: {
|
||||||
|
page: HistoryPage;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Verlaufsseiten" className="history-pagination" data-visual-region="history-pagination">
|
||||||
|
<span className="history-pagination-size">{page.pageSize} pro Seite</span>
|
||||||
|
<div className="history-pagination-controls">
|
||||||
|
<button
|
||||||
|
aria-label="Vorherige Verlaufsseite"
|
||||||
|
disabled={page.page <= 1}
|
||||||
|
onClick={() => onPageChange(page.page - 1)}
|
||||||
|
type="button"
|
||||||
|
>Zurück</button>
|
||||||
|
<span aria-atomic="true" aria-current="page" aria-live="polite" className="history-pagination-status">
|
||||||
|
<span>{page.rangeLabel}</span>
|
||||||
|
<span>{historyPageStatusLabel(page)}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-label="Nächste Verlaufsseite"
|
||||||
|
disabled={page.page >= page.totalPages}
|
||||||
|
onClick={() => onPageChange(page.page + 1)}
|
||||||
|
type="button"
|
||||||
|
>Vor</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryContentPageProps extends HistoryViewProps {
|
||||||
|
page: HistoryPage;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryContentPage({ model, actions, page, onPageChange }: HistoryContentPageProps): ReactElement {
|
||||||
const selected = new Set(model.selectedIds);
|
const selected = new Set(model.selectedIds);
|
||||||
const expanded = new Set(model.expandedIds);
|
const expanded = new Set(model.expandedIds);
|
||||||
const visibleIds = model.rows.map((row) => row.id);
|
const visibleIds = page.rows.map((row) => row.id);
|
||||||
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
||||||
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
|
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
|
||||||
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
||||||
? "Noch kein Verlauf"
|
? "Noch kein Verlauf"
|
||||||
: "Keine passenden Einträge";
|
: "Keine passenden Einträge";
|
||||||
|
const announcement = model.loading
|
||||||
|
? { role: "status" as const, live: "polite" as const, message: "Verlauf wird geladen. Die gespeicherten Einträge werden geladen." }
|
||||||
|
: model.error
|
||||||
|
? { role: "alert" as const, live: "assertive" as const, message: `${model.error}. Öffne die Ansicht erneut, um es noch einmal zu versuchen.` }
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Verlaufstabelle" className="history-content">
|
<section aria-label="Verlaufstabelle" className="history-content">
|
||||||
|
<h1 className="history-main-title">Verlauf</h1>
|
||||||
<DataTable className="history-table" label="Verlauf">
|
<DataTable className="history-table" label="Verlauf">
|
||||||
<DataTableHeader className="history-table-header">
|
<DataTableHeader className="history-table-header">
|
||||||
<div className="history-table-header-row" role="row">
|
<div className="history-table-header-row" role="row">
|
||||||
@@ -150,7 +196,7 @@ export function HistoryContent({ model, actions }: HistoryViewProps): ReactEleme
|
|||||||
) : showEmpty ? (
|
) : showEmpty ? (
|
||||||
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
|
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
|
||||||
) : (
|
) : (
|
||||||
model.rows.map((row) => {
|
page.rows.map((row) => {
|
||||||
const isSelected = selected.has(row.id);
|
const isSelected = selected.has(row.id);
|
||||||
const isExpanded = expanded.has(row.id);
|
const isExpanded = expanded.has(row.id);
|
||||||
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
||||||
@@ -212,23 +258,42 @@ export function HistoryContent({ model, actions }: HistoryViewProps): ReactEleme
|
|||||||
)}
|
)}
|
||||||
</DataTableBody>
|
</DataTableBody>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
{announcement ? (
|
||||||
|
<div role={announcement.role} aria-live={announcement.live} aria-atomic="true" className="history-announcement">
|
||||||
|
{announcement.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<HistoryPagination onPageChange={onPageChange} page={page} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
|
function PaginatedHistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||||
const count = model.rows.length;
|
const [requestedPage, setRequestedPage] = useState(1);
|
||||||
|
const page = paginateHistoryRows(model.rows, requestedPage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRequestedPage((current) => current === page.page ? current : page.page);
|
||||||
|
}, [page.page]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTableFooter
|
<HistoryContentPage
|
||||||
className="history-pagination"
|
actions={actions}
|
||||||
data-visual-region="history-pagination"
|
model={model}
|
||||||
pageSize={count}
|
onPageChange={setRequestedPage}
|
||||||
paginationVisible
|
page={page}
|
||||||
rangeLabel={count === 0 ? "0 von 0" : `1–${count} von ${count}`}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||||
|
return <PaginatedHistoryContent actions={actions} key={`${model.filter}\u0000${model.query}`} model={model} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryFooter(_props: Pick<HistoryViewProps, "model">): null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
|
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="history-workspace-view">
|
<div className="history-workspace-view">
|
||||||
@@ -236,7 +301,6 @@ export function HistoryView({ model, actions }: HistoryViewProps): ReactElement
|
|||||||
<div className="history-view-main">
|
<div className="history-view-main">
|
||||||
<HistoryToolbar actions={actions} model={model} />
|
<HistoryToolbar actions={actions} model={model} />
|
||||||
<HistoryContent actions={actions} model={model} />
|
<HistoryContent actions={actions} model={model} />
|
||||||
<HistoryFooter model={model} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ export interface HistoryViewModel {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HistoryPage {
|
||||||
|
rows: HistoryRow[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
rangeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HISTORY_PAGE_SIZE = 100;
|
||||||
|
|
||||||
const providerLabels: Record<DebridProvider, string> = {
|
const providerLabels: Record<DebridProvider, string> = {
|
||||||
realdebrid: "Real-Debrid",
|
realdebrid: "Real-Debrid",
|
||||||
megadebrid: "Mega-Debrid",
|
megadebrid: "Mega-Debrid",
|
||||||
@@ -58,6 +69,7 @@ const statusLabels: Record<HistoryViewStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||||
|
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
@@ -92,6 +104,27 @@ function formatDuration(durationSeconds: number): string {
|
|||||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
|
||||||
|
const totalItems = rows.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
|
||||||
|
const normalizedPage = Number.isFinite(requestedPage) ? Math.trunc(requestedPage) : 1;
|
||||||
|
const page = Math.min(totalPages, Math.max(1, normalizedPage));
|
||||||
|
const startIndex = (page - 1) * HISTORY_PAGE_SIZE;
|
||||||
|
const endIndex = Math.min(totalItems, startIndex + HISTORY_PAGE_SIZE);
|
||||||
|
const rangeLabel = totalItems === 0
|
||||||
|
? "0 von 0"
|
||||||
|
: `${integerFormatter.format(startIndex + 1)}–${integerFormatter.format(endIndex)} von ${integerFormatter.format(totalItems)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.slice(startIndex, endIndex),
|
||||||
|
page,
|
||||||
|
pageSize: HISTORY_PAGE_SIZE,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
rangeLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function localDayStart(timestamp: number): number {
|
function localDayStart(timestamp: number): number {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
date.setHours(0, 0, 0, 0);
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
.history-view-main {
|
.history-view-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,20 +108,46 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-action-danger:not(:disabled) {
|
.history-action-danger:not(:disabled) {
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-content {
|
.history-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-main-title {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--ui-border);
|
||||||
|
color: var(--ui-text);
|
||||||
|
display: flex;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-announcement {
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.history-content .history-table {
|
.history-content .history-table {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,19 +281,19 @@
|
|||||||
.history-status-completed {
|
.history-status-completed {
|
||||||
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
|
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
|
||||||
color: var(--ui-success);
|
color: var(--ui-success-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-status-deleted {
|
.history-status-deleted {
|
||||||
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-status-failed {
|
.history-status-failed {
|
||||||
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row-size,
|
.history-row-size,
|
||||||
@@ -335,11 +361,56 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-table-error .ui-data-table-empty-title {
|
.history-table-error .ui-data-table-empty-title {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-pagination {
|
.history-pagination {
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--ui-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px 10px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pagination-size,
|
||||||
|
.history-pagination-status {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pagination-controls,
|
||||||
|
.history-pagination-status {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pagination button {
|
||||||
|
background: var(--ui-input);
|
||||||
|
border: 1px solid var(--ui-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--ui-text);
|
||||||
|
font: inherit;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pagination button:hover:not(:disabled) {
|
||||||
|
background: var(--ui-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pagination button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-shell-main:has(.history-content) > .md-shell-footer {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1366px) {
|
@media (max-width: 1366px) {
|
||||||
|
|||||||
@@ -14,14 +14,33 @@ import {
|
|||||||
DataTableHeader
|
DataTableHeader
|
||||||
} from "../../ui/DataTable";
|
} from "../../ui/DataTable";
|
||||||
import { Dialog } from "../../ui/Dialog";
|
import { Dialog } from "../../ui/Dialog";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_COLUMNS,
|
ACCOUNT_COLUMNS,
|
||||||
type AccountAddFilter,
|
getSettingsSelectNavigationIndex,
|
||||||
|
type AccountAddFilter,
|
||||||
type AccountAddOption,
|
type AccountAddOption,
|
||||||
type AccountRowViewModel
|
type AccountRowViewModel
|
||||||
} from "./settings-model";
|
} from "./settings-model";
|
||||||
|
|
||||||
export type AccountWorkspacePanel = "overview" | "rules";
|
export type AccountWorkspacePanel = "overview" | "rules";
|
||||||
|
|
||||||
|
const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: string }[] = [
|
||||||
|
{ id: "overview", label: "Übersicht" },
|
||||||
|
{ id: "rules", label: "Verwendungsregeln" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function getAccountPanelNavigationIndex(currentIndex: number, key: string): number | null {
|
||||||
|
if (key === "ArrowRight") {
|
||||||
|
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, "ArrowDown");
|
||||||
|
}
|
||||||
|
if (key === "ArrowLeft") {
|
||||||
|
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, "ArrowUp");
|
||||||
|
}
|
||||||
|
if (key === "Home" || key === "End") {
|
||||||
|
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountRulesViewModel {
|
export interface AccountRulesViewModel {
|
||||||
providerOrder: readonly string[];
|
providerOrder: readonly string[];
|
||||||
@@ -253,7 +272,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
|
|||||||
const selectedIds = new Set(model.selectedIds);
|
const selectedIds = new Set(model.selectedIds);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable className="settings-account-table" label="Accounts">
|
<DataTable aria-busy={model.busy} className="settings-account-table" label="Accounts">
|
||||||
<DataTableHeader className="settings-account-table-header">
|
<DataTableHeader className="settings-account-table-header">
|
||||||
<div className="settings-account-table-grid" role="row">
|
<div className="settings-account-table-grid" role="row">
|
||||||
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
|
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
|
||||||
@@ -412,9 +431,11 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
|
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="settings-account-workspace">
|
<div className="settings-account-workspace">
|
||||||
<header className="settings-account-heading">
|
{model.busy ? <span aria-live="polite" className="settings-visually-hidden" role="status">Accountdaten werden aktualisiert.</span> : null}
|
||||||
|
{model.error ? <span className="settings-visually-hidden" role="alert">{model.error}</span> : null}
|
||||||
|
<header className="settings-account-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>Accountverwaltung</h2>
|
<h2>Accountverwaltung</h2>
|
||||||
<p>Accounts hinzufügen, prüfen und verwalten.</p>
|
<p>Accounts hinzufügen, prüfen und verwalten.</p>
|
||||||
@@ -431,27 +452,32 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
<SlidingSelection activeKey={model.activePanel} aria-label="Accountverwaltung" axis="horizontal" className="settings-account-tabs" role="tablist">
|
<SlidingSelection activeKey={model.activePanel} aria-label="Accountverwaltung" aria-orientation="horizontal" axis="horizontal" className="settings-account-tabs" role="tablist">
|
||||||
<button
|
{ACCOUNT_WORKSPACE_PANELS.map((panel, index) => (
|
||||||
aria-controls="settings-account-overview"
|
<button
|
||||||
aria-selected={model.activePanel === "overview"}
|
aria-controls={`settings-account-${panel.id}`}
|
||||||
data-sliding-selection-active={model.activePanel === "overview"}
|
aria-selected={model.activePanel === panel.id}
|
||||||
data-sliding-selection-item="true"
|
data-sliding-selection-active={model.activePanel === panel.id}
|
||||||
id="settings-account-overview-tab"
|
data-sliding-selection-item="true"
|
||||||
onClick={() => actions.onPanelChange("overview")}
|
id={`settings-account-${panel.id}-tab`}
|
||||||
role="tab"
|
key={panel.id}
|
||||||
type="button"
|
onClick={() => actions.onPanelChange(panel.id)}
|
||||||
>Übersicht</button>
|
onKeyDown={(event) => {
|
||||||
<button
|
const nextIndex = getAccountPanelNavigationIndex(index, event.key);
|
||||||
aria-controls="settings-account-rules"
|
if (nextIndex === null) {
|
||||||
aria-selected={model.activePanel === "rules"}
|
return;
|
||||||
data-sliding-selection-active={model.activePanel === "rules"}
|
}
|
||||||
data-sliding-selection-item="true"
|
event.preventDefault();
|
||||||
id="settings-account-rules-tab"
|
const tablist = event.currentTarget.closest('[role="tablist"]');
|
||||||
onClick={() => actions.onPanelChange("rules")}
|
const tabs = tablist?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||||
role="tab"
|
tabs?.[nextIndex]?.focus();
|
||||||
type="button"
|
actions.onPanelChange(ACCOUNT_WORKSPACE_PANELS[nextIndex].id);
|
||||||
>Verwendungsregeln</button>
|
}}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={model.activePanel === panel.id ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>{panel.label}</button>
|
||||||
|
))}
|
||||||
</SlidingSelection>
|
</SlidingSelection>
|
||||||
<div
|
<div
|
||||||
aria-labelledby="settings-account-overview-tab"
|
aria-labelledby="settings-account-overview-tab"
|
||||||
@@ -502,6 +528,8 @@ export function AccountAddDialog({
|
|||||||
<div className="settings-account-picker-selector">
|
<div className="settings-account-picker-selector">
|
||||||
<span>Dienst / Zugangstyp</span>
|
<span>Dienst / Zugangstyp</span>
|
||||||
<input
|
<input
|
||||||
|
aria-controls={model.options.length === 0 ? "settings-account-picker-empty" : "settings-account-picker-results"}
|
||||||
|
aria-describedby={model.options.length === 0 ? "settings-account-picker-empty" : undefined}
|
||||||
aria-label="Dienst oder Zugangstyp suchen"
|
aria-label="Dienst oder Zugangstyp suchen"
|
||||||
className="settings-control"
|
className="settings-control"
|
||||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||||
@@ -515,8 +543,13 @@ export function AccountAddDialog({
|
|||||||
<span>Dienst</span>
|
<span>Dienst</span>
|
||||||
<span>Typ/Funktion</span>
|
<span>Typ/Funktion</span>
|
||||||
</div>
|
</div>
|
||||||
<div aria-label="Dienst / Zugangstyp" className="settings-account-picker-list" role="listbox">
|
{model.options.length === 0 ? (
|
||||||
{model.options.map((option) => (
|
<div aria-live="polite" className="settings-account-picker-empty" id="settings-account-picker-empty" role="status">
|
||||||
|
Keine passenden Dienste oder Zugangstypen gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div aria-label="Dienst / Zugangstyp" className="settings-account-picker-list" id="settings-account-picker-results" role="listbox">
|
||||||
|
{model.options.map((option) => (
|
||||||
<button
|
<button
|
||||||
aria-selected={option.id === model.selectedOptionId}
|
aria-selected={option.id === model.selectedOptionId}
|
||||||
className={`settings-account-picker-row${option.id === model.selectedOptionId ? " is-selected" : ""}`}
|
className={`settings-account-picker-row${option.id === model.selectedOptionId ? " is-selected" : ""}`}
|
||||||
@@ -532,8 +565,9 @@ export function AccountAddDialog({
|
|||||||
</span>
|
</span>
|
||||||
<span>{option.functionLabel}</span>
|
<span>{option.functionLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedOption ? (
|
{selectedOption ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -13,10 +13,50 @@ export interface SettingsFormActions {
|
|||||||
onCommit?: (fieldId: string, value: string) => void;
|
onCommit?: (fieldId: string, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsFormProps {
|
export interface SettingsFormProps {
|
||||||
model: SettingsFormViewModel;
|
model: SettingsFormViewModel;
|
||||||
actions: SettingsFormActions;
|
actions: SettingsFormActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SettingsSelectKeyboardAction =
|
||||||
|
| { type: "close" }
|
||||||
|
| { type: "focus"; index: number }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function getSettingsSelectKeyboardAction(
|
||||||
|
key: string,
|
||||||
|
currentIndex: number,
|
||||||
|
optionCount: number
|
||||||
|
): SettingsSelectKeyboardAction {
|
||||||
|
if (key === "Escape") {
|
||||||
|
return { type: "close" };
|
||||||
|
}
|
||||||
|
if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") {
|
||||||
|
return { type: "focus", index: getSettingsSelectNavigationIndex(currentIndex, optionCount, key) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSettingsSelectAndRestoreFocus(
|
||||||
|
close: () => void,
|
||||||
|
trigger: Pick<HTMLButtonElement, "focus"> | null
|
||||||
|
): void {
|
||||||
|
close();
|
||||||
|
trigger?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemeNavigationIndex(currentIndex: number, optionCount: number, key: string): number | null {
|
||||||
|
if (key === "ArrowRight" || key === "ArrowDown") {
|
||||||
|
return getSettingsSelectNavigationIndex(currentIndex, optionCount, "ArrowDown");
|
||||||
|
}
|
||||||
|
if (key === "ArrowLeft" || key === "ArrowUp") {
|
||||||
|
return getSettingsSelectNavigationIndex(currentIndex, optionCount, "ArrowUp");
|
||||||
|
}
|
||||||
|
if (key === "Home" || key === "End") {
|
||||||
|
return getSettingsSelectNavigationIndex(currentIndex, optionCount, key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
|
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
|
||||||
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
|
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
|
||||||
@@ -87,28 +127,27 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
|||||||
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus());
|
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeAndRestoreFocus = (): void => {
|
||||||
|
closeSettingsSelectAndRestoreFocus(() => setOpen(false), triggerRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (event: MouseEvent): void => {
|
const close = (event: MouseEvent): void => {
|
||||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
||||||
};
|
};
|
||||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
|
||||||
if (event.key === "Escape") setOpen(false);
|
|
||||||
};
|
|
||||||
window.addEventListener("mousedown", close);
|
window.addEventListener("mousedown", close);
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("mousedown", close);
|
window.removeEventListener("mousedown", close);
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
const action = getSettingsSelectKeyboardAction(event.key, selectedIndex, field.options.length);
|
||||||
|
if (action?.type === "focus") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const nextIndex = getSettingsSelectNavigationIndex(selectedIndex, field.options.length, event.key);
|
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
focusOption(nextIndex);
|
focusOption(action.index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
@@ -119,17 +158,20 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
|
const onOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
const action = getSettingsSelectKeyboardAction(event.key, index, field.options.length);
|
||||||
|
if (action?.type === "focus") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const nextIndex = getSettingsSelectNavigationIndex(index, field.options.length, event.key);
|
focusOption(action.index);
|
||||||
focusOption(nextIndex);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||||
|
if (!open || getSettingsSelectKeyboardAction(event.key, selectedIndex, field.options.length)?.type !== "close") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
event.stopPropagation();
|
||||||
setOpen(false);
|
closeAndRestoreFocus();
|
||||||
triggerRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||||
@@ -139,7 +181,7 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
|||||||
return (
|
return (
|
||||||
<div className="settings-field">
|
<div className="settings-field">
|
||||||
<label id={`${field.id}-label`}>{field.label}</label>
|
<label id={`${field.id}-label`}>{field.label}</label>
|
||||||
<div className={`settings-select${open ? " is-open" : ""}${field.disabled ? " is-disabled" : ""}`} onBlur={onBlur} ref={rootRef}>
|
<div className={`settings-select${open ? " is-open" : ""}${field.disabled ? " is-disabled" : ""}`} onBlur={onBlur} onKeyDown={onSelectKeyDown} ref={rootRef}>
|
||||||
<button
|
<button
|
||||||
aria-controls={`${field.id}-options`}
|
aria-controls={`${field.id}-options`}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
@@ -165,11 +207,12 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
actions.onChange(field.id, option.value);
|
actions.onChange(field.id, option.value);
|
||||||
setOpen(false);
|
closeAndRestoreFocus();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => onOptionKeyDown(event, index)}
|
onKeyDown={(event) => onOptionKeyDown(event, index)}
|
||||||
ref={(element) => { optionRefs.current[index] = element; }}
|
ref={(element) => { optionRefs.current[index] = element; }}
|
||||||
role="option"
|
role="option"
|
||||||
|
tabIndex={-1}
|
||||||
type="button"
|
type="button"
|
||||||
>{option.label}</button>
|
>{option.label}</button>
|
||||||
))}
|
))}
|
||||||
@@ -187,19 +230,32 @@ function SettingsField({ field, actions }: { field: SettingsFieldViewModel; acti
|
|||||||
if (field.kind === "select") {
|
if (field.kind === "select") {
|
||||||
return <SelectControl actions={actions} field={field} />;
|
return <SelectControl actions={actions} field={field} />;
|
||||||
}
|
}
|
||||||
if (field.kind === "theme") {
|
if (field.kind === "theme") {
|
||||||
return (
|
const selectedIndex = Math.max(0, field.options.findIndex((option) => option.value === field.value));
|
||||||
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
|
return (
|
||||||
<legend>{field.label}</legend>
|
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
|
||||||
<div aria-describedby={field.help ? `${field.id}-help` : undefined} className="settings-theme-options" role="radiogroup">
|
<legend id={`${field.id}-label`}>{field.label}</legend>
|
||||||
{field.options.map((option) => (
|
<div aria-describedby={field.help ? `${field.id}-help` : undefined} aria-labelledby={`${field.id}-label`} className="settings-theme-options" role="radiogroup">
|
||||||
<button
|
{field.options.map((option, index) => (
|
||||||
aria-checked={field.value === option.value}
|
<button
|
||||||
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
|
aria-checked={field.value === option.value}
|
||||||
key={option.value}
|
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
|
||||||
onClick={() => actions.onChange(field.id, option.value)}
|
key={option.value}
|
||||||
role="radio"
|
onClick={() => actions.onChange(field.id, option.value)}
|
||||||
type="button"
|
onKeyDown={(event) => {
|
||||||
|
const nextIndex = getThemeNavigationIndex(index, field.options.length, event.key);
|
||||||
|
if (nextIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const group = event.currentTarget.closest('[role="radiogroup"]');
|
||||||
|
const radios = group?.querySelectorAll<HTMLElement>('[role="radio"]');
|
||||||
|
radios?.[nextIndex]?.focus();
|
||||||
|
actions.onChange(field.id, field.options[nextIndex].value);
|
||||||
|
}}
|
||||||
|
role="radio"
|
||||||
|
tabIndex={index === selectedIndex ? 0 : -1}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
|
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
|
|||||||
@@ -117,13 +117,18 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-save-state.is-clean,
|
||||||
|
.settings-save-state.is-saved {
|
||||||
|
color: var(--ui-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-save-state.is-dirty,
|
.settings-save-state.is-dirty,
|
||||||
.settings-save-state.is-saving {
|
.settings-save-state.is-saving {
|
||||||
color: var(--ui-warning);
|
color: var(--ui-warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-save-state.is-error {
|
.settings-save-state.is-error {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-content-body {
|
.settings-content-body {
|
||||||
@@ -209,7 +214,7 @@
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-control-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
@@ -231,7 +236,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid var(--ui-border);
|
border: 1px solid var(--ui-control-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--ui-input);
|
background: var(--ui-input);
|
||||||
color: var(--ui-text);
|
color: var(--ui-text);
|
||||||
@@ -312,7 +317,7 @@
|
|||||||
.settings-account-row:focus-visible,
|
.settings-account-row:focus-visible,
|
||||||
.settings-account-action-button:focus-visible,
|
.settings-account-action-button:focus-visible,
|
||||||
.settings-account-picker-row:focus-visible {
|
.settings-account-picker-row:focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,13 +475,13 @@
|
|||||||
.settings-button-primary {
|
.settings-button-primary {
|
||||||
border-color: var(--ui-primary);
|
border-color: var(--ui-primary);
|
||||||
background: var(--ui-primary);
|
background: var(--ui-primary);
|
||||||
color: #181A1F;
|
color: var(--ui-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-button-danger {
|
.settings-button-danger {
|
||||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-button:hover:not(:disabled),
|
.settings-button:hover:not(:disabled),
|
||||||
@@ -488,7 +493,7 @@
|
|||||||
|
|
||||||
.settings-button-primary:hover:not(:disabled) {
|
.settings-button-primary:hover:not(:disabled) {
|
||||||
background: var(--ui-primary-hover);
|
background: var(--ui-primary-hover);
|
||||||
color: #181A1F;
|
color: var(--ui-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-workspace {
|
.settings-account-workspace {
|
||||||
@@ -677,15 +682,28 @@
|
|||||||
background: var(--ui-success);
|
background: var(--ui-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-status-badge.is-ok {
|
||||||
|
color: var(--ui-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-status-badge.is-free::before,
|
.settings-account-status-badge.is-free::before,
|
||||||
.settings-account-status-badge.is-unknown::before {
|
.settings-account-status-badge.is-unknown::before {
|
||||||
background: var(--ui-warning);
|
background: var(--ui-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-status-badge.is-free,
|
||||||
|
.settings-account-status-badge.is-unknown {
|
||||||
|
color: var(--ui-warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-status-badge.is-invalid::before {
|
.settings-account-status-badge.is-invalid::before {
|
||||||
background: var(--ui-danger);
|
background: var(--ui-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-status-badge.is-invalid {
|
||||||
|
color: var(--ui-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-status-badge.is-disabled::before {
|
.settings-account-status-badge.is-disabled::before {
|
||||||
background: var(--ui-text-muted);
|
background: var(--ui-text-muted);
|
||||||
}
|
}
|
||||||
@@ -746,7 +764,7 @@
|
|||||||
|
|
||||||
.settings-account-table-error .ui-data-table-empty-title,
|
.settings-account-table-error .ui-data-table-empty-title,
|
||||||
.settings-account-dialog-error {
|
.settings-account-dialog-error {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-rules {
|
.settings-account-rules {
|
||||||
@@ -879,6 +897,16 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-picker-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 16px;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--ui-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-picker-row {
|
.settings-account-picker-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -905,6 +933,17 @@
|
|||||||
box-shadow: inset 3px 0 0 var(--ui-accent);
|
box-shadow: inset 3px 0 0 var(--ui-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
border: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-picker-service {
|
.settings-account-picker-service {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
.statistics-range:focus-visible,
|
.statistics-range:focus-visible,
|
||||||
.statistics-reset:focus-visible {
|
.statistics-reset:focus-visible {
|
||||||
outline: 2px solid var(--ui-accent);
|
outline: 2px solid var(--ui-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset-danger {
|
.statistics-reset-danger {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-reset:disabled {
|
.statistics-reset:disabled {
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpi-danger .statistics-kpi-value {
|
.statistics-kpi-danger .statistics-kpi-value {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-kpi-source {
|
.statistics-kpi-source {
|
||||||
@@ -309,7 +309,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-errors {
|
.statistics-provider-errors {
|
||||||
color: var(--ui-danger);
|
color: var(--ui-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-provider-empty {
|
.statistics-provider-empty {
|
||||||
|
|||||||
@@ -8,6 +8,30 @@ import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
|||||||
import { getSnapshotRenderDelay } from "../src/renderer/App";
|
import { getSnapshotRenderDelay } from "../src/renderer/App";
|
||||||
|
|
||||||
describe("desktop shell", () => {
|
describe("desktop shell", () => {
|
||||||
|
it("uses keyboard-focusable controls for every copy target", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
|
||||||
|
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms before removing a collector tab", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
const removal = source.slice(source.indexOf("const removeCollectorTab"), source.indexOf("const openCollectorInput"));
|
||||||
|
|
||||||
|
expect(removal).toContain("askConfirmPrompt");
|
||||||
|
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("planCollectorTabRemoval"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms before removing selected collector links", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
const removal = source.slice(source.indexOf("const removeSelectedCollectorRows"), source.indexOf("const onPackageStartEdit"));
|
||||||
|
|
||||||
|
expect(removal).toContain("askConfirmPrompt");
|
||||||
|
expect(removal.indexOf("askConfirmPrompt")).toBeLessThan(removal.indexOf("setCollectorTabs"));
|
||||||
|
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||||
|
});
|
||||||
|
|
||||||
it("does not stack renderer latency on the manager cadence for large active queues", () => {
|
it("does not stack renderer latency on the manager cadence for large active queues", () => {
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
import { readFileSync } from "node:fs";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -185,7 +186,7 @@ describe("CollectorView", () => {
|
|||||||
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
|
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders compact occupied rows and removes the empty marker", () => {
|
it("renders compact occupied rows and removes the empty marker", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<CollectorView
|
<CollectorView
|
||||||
actions={createActions()}
|
actions={createActions()}
|
||||||
@@ -199,10 +200,58 @@ describe("CollectorView", () => {
|
|||||||
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
|
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
|
||||||
expect(html).toContain("data-visual-region=\"collector-table-body\"");
|
expect(html).toContain("data-visual-region=\"collector-table-body\"");
|
||||||
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
|
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
|
||||||
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
it("gives every row checkbox a unique accessible name with its link and collection", () => {
|
||||||
|
const content = CollectorContent({
|
||||||
|
actions: createActions(),
|
||||||
|
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])
|
||||||
|
});
|
||||||
|
const labels: string[] = [];
|
||||||
|
visitElements(content, (element) => {
|
||||||
|
if (element.type === "input" && element.props.type === "checkbox") {
|
||||||
|
labels.push(element.props["aria-label"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(labels).toEqual([
|
||||||
|
"https://example.test/a aus Sammlung A, Zeile 1 auswählen",
|
||||||
|
"https://example.test/b aus Sammlung A, Zeile 3 auswählen"
|
||||||
|
]);
|
||||||
|
expect(new Set(labels).size).toBe(labels.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a high-contrast table heading token in both themes", () => {
|
||||||
|
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.collector-table-header-row\s*{[^}]*color:\s*var\(--ui-text-secondary\);/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the semantic danger text token for the removal action", () => {
|
||||||
|
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.collector-action-danger:not\(:disabled\)\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables queue submission only when the active collection has no links", () => {
|
||||||
|
const emptyActive = CollectorToolbar({
|
||||||
|
actions: createActions(),
|
||||||
|
model: buildCollectorViewModel([
|
||||||
|
{ id: "tab-a", name: "Sammlung A", text: "" },
|
||||||
|
{ id: "tab-b", name: "Sammlung B", text: "https://example.test/b" }
|
||||||
|
], "tab-a", "", false, [])
|
||||||
|
});
|
||||||
|
const filteredActive = CollectorToolbar({
|
||||||
|
actions: createActions(),
|
||||||
|
model: buildCollectorViewModel(populatedTabs, "tab-a", "kein-treffer", false, [])
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findButton(emptyActive, "An Downloads übergeben").props.disabled).toBe(true);
|
||||||
|
expect(findButton(filteredActive, "An Downloads übergeben").props.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
|
||||||
let inputOpens = 0;
|
let inputOpens = 0;
|
||||||
let queueSubmits = 0;
|
let queueSubmits = 0;
|
||||||
let query = "";
|
let query = "";
|
||||||
@@ -235,7 +284,7 @@ describe("CollectorView", () => {
|
|||||||
search.props.onChange({ target: { value: "release" } });
|
search.props.onChange({ target: { value: "release" } });
|
||||||
expect(query).toBe("release");
|
expect(query).toBe("release");
|
||||||
|
|
||||||
const checkbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Link auswählen");
|
const checkbox = findElement(content, (element) => element.type === "input" && element.props.type === "checkbox");
|
||||||
checkbox.props.onChange();
|
checkbox.props.onChange();
|
||||||
findButton(toolbar, "Auswahl entfernen").props.onClick();
|
findButton(toolbar, "Auswahl entfernen").props.onClick();
|
||||||
expect(selected).toBe("tab-a:0");
|
expect(selected).toBe("tab-a:0");
|
||||||
@@ -243,7 +292,7 @@ describe("CollectorView", () => {
|
|||||||
expect(queueSubmits).toBe(1);
|
expect(queueSubmits).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the input dialog and commits only through the local draft callback", () => {
|
it("names the input dialog and commits only through the local draft callback", () => {
|
||||||
let value = "";
|
let value = "";
|
||||||
let commits = 0;
|
let commits = 0;
|
||||||
const dialog = CollectorInputDialog({
|
const dialog = CollectorInputDialog({
|
||||||
@@ -265,6 +314,13 @@ describe("CollectorView", () => {
|
|||||||
textbox.props.onChange({ target: { value: "https://example.test/new" } });
|
textbox.props.onChange({ target: { value: "https://example.test/new" } });
|
||||||
findButton(dialog, "Übernehmen").props.onClick();
|
findButton(dialog, "Übernehmen").props.onClick();
|
||||||
expect(value).toBe("https://example.test/new");
|
expect(value).toBe("https://example.test/new");
|
||||||
expect(commits).toBe(1);
|
expect(commits).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it("moves the search field onto a separate compact row instead of overlapping actions", () => {
|
||||||
|
const css = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar\s*\{[^}]*flex-wrap:\s*wrap;/s);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.collector-toolbar \.ui-toolbar-search\s*\{[^}]*flex:\s*1 0 100%;[^}]*width:\s*100%;/s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
|||||||
|
|
||||||
describe("Downloadtabellen-Spalten", () => {
|
describe("Downloadtabellen-Spalten", () => {
|
||||||
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
|
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
|
||||||
expect(downloadColumnDefinitions.name.width).toBe("minmax(290px, 2.3fr)");
|
expect(downloadColumnDefinitions.name.width).toBe("minmax(var(--downloads-name-min, 290px), 2.3fr)");
|
||||||
expect(downloadColumnDefinitions.progress.width).toBe("minmax(105px, 0.85fr)");
|
expect(downloadColumnDefinitions.progress.width).toBe("minmax(var(--downloads-progress-min, 105px), 0.85fr)");
|
||||||
expect(downloadColumnDefinitions.prio.width).toBe("minmax(85px, 0.8fr)");
|
expect(downloadColumnDefinitions.prio.width).toBe("minmax(var(--downloads-priority-min, 85px), 0.8fr)");
|
||||||
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(120px, 1fr)" }));
|
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" }));
|
||||||
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(110px, 1fr)" }));
|
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the normal text color for sortable and static column headers", () => {
|
it("uses the normal text color for sortable and static column headers", () => {
|
||||||
@@ -484,6 +484,7 @@ describe("downloads view", () => {
|
|||||||
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
|
||||||
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
|
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
|
||||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
|
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables the service filter until more than one concrete service is available", () => {
|
it("disables the service filter until more than one concrete service is available", () => {
|
||||||
@@ -668,7 +669,7 @@ describe("downloads view", () => {
|
|||||||
expect(html).toContain('class="downloads-package-items is-expanded"');
|
expect(html).toContain('class="downloads-package-items is-expanded"');
|
||||||
expect(html).toContain('class="downloads-package-items-inner"');
|
expect(html).toContain('class="downloads-package-items-inner"');
|
||||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
|
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
|
||||||
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*1191px;/s);
|
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*var\(--downloads-table-min-width, 1191px\);/s);
|
||||||
expect(css).not.toMatch(/min-width:\s*max-content;/);
|
expect(css).not.toMatch(/min-width:\s*max-content;/);
|
||||||
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
|
||||||
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s);
|
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s);
|
||||||
@@ -678,6 +679,7 @@ describe("downloads view", () => {
|
|||||||
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
|
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
|
||||||
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
|
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
|
||||||
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-column-header\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
|
||||||
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
||||||
@@ -688,8 +690,10 @@ describe("downloads view", () => {
|
|||||||
expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s);
|
expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s);
|
||||||
expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
||||||
expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
||||||
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*inline;/s);
|
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*:is\(\.downloads-status-cell, \.downloads-service-cell\)\s*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
|
||||||
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*inline;/s);
|
expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s);
|
||||||
|
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*block;/s);
|
||||||
|
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*block;/s);
|
||||||
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
||||||
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
||||||
@@ -720,10 +724,13 @@ describe("downloads view", () => {
|
|||||||
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
|
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
|
it("keeps the action column visible at 1366px and 1120px through the production wrapper contract", () => {
|
||||||
|
const html = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
expect(css).toMatch(/@media \(max-width:\s*1120px\)/);
|
expect(html).toMatch(/^<main class="downloads-content">/);
|
||||||
|
expect(css).toMatch(/\.md-shell\.is-compact \.downloads-content,\s*\.md-shell\.is-minimum \.downloads-content\s*\{[^}]*--downloads-table-min-width:\s*1016px;[^}]*--downloads-name-min:\s*180px;[^}]*--downloads-status-min:\s*90px;/s);
|
||||||
|
expect(css).not.toMatch(/\.md-shell\.is-(?:compact|minimum) \.downloads-view/);
|
||||||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*overflow:\s*hidden;/s);
|
expect(css).toMatch(/\.downloads-content\s*\{[^}]*overflow:\s*hidden;/s);
|
||||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*auto;/s);
|
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*auto;/s);
|
||||||
});
|
});
|
||||||
@@ -838,6 +845,9 @@ describe("download table row contracts", () => {
|
|||||||
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*16px 4ch 1ch 4ch auto;[^}]*font-variant-numeric:\s*tabular-nums;/s);
|
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*16px 4ch 1ch 4ch auto;[^}]*font-variant-numeric:\s*tabular-nums;/s);
|
||||||
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
|
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
|
||||||
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
|
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-availability\.is-online\s*\{[^}]*color:\s*var\(--ui-success-text\);/s);
|
||||||
|
expect(css).toMatch(/\.downloads-availability\.is-partial\s*\{[^}]*color:\s*var\(--ui-warning-text\);/s);
|
||||||
|
expect(css).toMatch(/\.downloads-availability\.is-offline\s*\{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the package download and extraction phase split", () => {
|
it("preserves the package download and extraction phase split", () => {
|
||||||
@@ -936,12 +946,53 @@ describe("download table row contracts", () => {
|
|||||||
visibleIds: ["package-a", "active", "queued"]
|
visibleIds: ["package-a", "active", "queued"]
|
||||||
});
|
});
|
||||||
const checkbox = findElement(header, (element) => element.type === "input");
|
const checkbox = findElement(header, (element) => element.type === "input");
|
||||||
|
const input = { indeterminate: false };
|
||||||
|
|
||||||
|
(checkbox as unknown as { ref: (element: typeof input) => void }).ref(input);
|
||||||
checkbox.props.onChange({ target: { checked: true } });
|
checkbox.props.onChange({ target: { checked: true } });
|
||||||
|
|
||||||
|
expect(checkbox.props["aria-checked"]).toBe("mixed");
|
||||||
|
expect(input.indeterminate).toBe(true);
|
||||||
expect(calls).toEqual([[['package-a', 'active', 'queued'], true]]);
|
expect(calls).toEqual([[['package-a', 'active', 'queued'], true]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("announces sort state and exposes keyboard-operable column move controls", () => {
|
||||||
|
const calls: Array<[string, string, number]> = [];
|
||||||
|
const header = DownloadsTableHeader({
|
||||||
|
actions: createActions({
|
||||||
|
onColumnPointerDown: (column, event) => calls.push(["down", column, event.clientX]),
|
||||||
|
onColumnPointerMove: (column, event) => calls.push(["move", column, event.clientX]),
|
||||||
|
onColumnPointerUp: (column, event) => calls.push(["up", column, event.clientX])
|
||||||
|
}),
|
||||||
|
columnOrder: ["name", "size", "account"],
|
||||||
|
gridTemplate: "200px 100px 100px",
|
||||||
|
selectedCount: 0,
|
||||||
|
sortColumn: "name",
|
||||||
|
sortDirection: "desc",
|
||||||
|
visibleIds: ["package-a"]
|
||||||
|
});
|
||||||
|
const html = renderToStaticMarkup(header);
|
||||||
|
const moveLeft = findElement(header, (element) => element.type === "button" && element.props["aria-label"] === "Geladen / Größe nach links verschieben");
|
||||||
|
const previous = { getBoundingClientRect: () => ({ left: 100, width: 100 }), matches: () => true };
|
||||||
|
const current = {
|
||||||
|
getBoundingClientRect: () => ({ left: 200, width: 100 }),
|
||||||
|
previousElementSibling: previous,
|
||||||
|
nextElementSibling: null
|
||||||
|
};
|
||||||
|
|
||||||
|
moveLeft.props.onClick({ currentTarget: { closest: () => current }, stopPropagation: () => {} });
|
||||||
|
|
||||||
|
expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/);
|
||||||
|
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/);
|
||||||
|
expect(html).not.toMatch(/aria-sort="[^"]+"[^>]*data-download-column="account"/);
|
||||||
|
expect(moveLeft.props.type).toBe("button");
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["down", "size", 250],
|
||||||
|
["move", "size", 149],
|
||||||
|
["up", "size", 149]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes package selection state in memo equality", () => {
|
it("includes package selection state in memo equality", () => {
|
||||||
const model = withRuntime(createInput());
|
const model = withRuntime(createInput());
|
||||||
const row = model.packageRows[0];
|
const row = model.packageRows[0];
|
||||||
|
|||||||
+120
-8
@@ -8,6 +8,8 @@ import {
|
|||||||
deriveHistoryHoster,
|
deriveHistoryHoster,
|
||||||
deriveHistoryStartAt,
|
deriveHistoryStartAt,
|
||||||
filterHistoryRows,
|
filterHistoryRows,
|
||||||
|
HISTORY_PAGE_SIZE,
|
||||||
|
paginateHistoryRows,
|
||||||
pruneHistoryIds,
|
pruneHistoryIds,
|
||||||
selectVisibleHistoryIds,
|
selectVisibleHistoryIds,
|
||||||
type HistoryFilter,
|
type HistoryFilter,
|
||||||
@@ -15,11 +17,14 @@ import {
|
|||||||
} from "../src/renderer/views/history/history-model";
|
} from "../src/renderer/views/history/history-model";
|
||||||
import {
|
import {
|
||||||
HistoryContent,
|
HistoryContent,
|
||||||
|
HistoryContentPage,
|
||||||
|
HistoryPagination,
|
||||||
HistorySidebar,
|
HistorySidebar,
|
||||||
HistoryToolbar,
|
HistoryToolbar,
|
||||||
HistoryView,
|
HistoryView,
|
||||||
type HistoryViewActions
|
historyPageStatusLabel,
|
||||||
} from "../src/renderer/views/history/HistoryView";
|
type HistoryViewActions
|
||||||
|
} from "../src/renderer/views/history/HistoryView";
|
||||||
import { createVisualFixture } from "./visual/fixtures";
|
import { createVisualFixture } from "./visual/fixtures";
|
||||||
import { createVisualElectronApi } from "./visual/mock-electron-api";
|
import { createVisualElectronApi } from "./visual/mock-electron-api";
|
||||||
|
|
||||||
@@ -179,6 +184,30 @@ describe("history model", () => {
|
|||||||
expect([...selectVisibleHistoryIds(visibleIds)]).toEqual(["week-edge", "week"]);
|
expect([...selectVisibleHistoryIds(visibleIds)]).toEqual(["week-edge", "week"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("splits large filtered results into stable pages and clamps invalid page requests", () => {
|
||||||
|
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
|
||||||
|
const rows = Array.from({ length: 100_005 }, (_, index) => ({
|
||||||
|
...template,
|
||||||
|
id: `row-${index + 1}`,
|
||||||
|
name: `Eintrag ${index + 1}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const first = paginateHistoryRows(rows, 1);
|
||||||
|
const last = paginateHistoryRows(rows, 2_000);
|
||||||
|
|
||||||
|
expect(HISTORY_PAGE_SIZE).toBe(100);
|
||||||
|
expect(first.rows).toHaveLength(100);
|
||||||
|
expect(first.rows[0].id).toBe("row-1");
|
||||||
|
expect(first.rows[99].id).toBe("row-100");
|
||||||
|
expect(first.page).toBe(1);
|
||||||
|
expect(first.totalPages).toBe(1_001);
|
||||||
|
expect(first.rangeLabel).toBe("1–100 von 100.005");
|
||||||
|
expect(last.rows).toHaveLength(5);
|
||||||
|
expect(last.rows[0].id).toBe("row-100001");
|
||||||
|
expect(last.page).toBe(1_001);
|
||||||
|
expect(last.rangeLabel).toBe("100.001–100.005 von 100.005");
|
||||||
|
});
|
||||||
|
|
||||||
it("removes hidden selected ids from the filtered view model and every toolbar action", () => {
|
it("removes hidden selected ids from the filtered view model and every toolbar action", () => {
|
||||||
const model = buildHistoryViewModel(entries, "deleted", "", ["today", "week"], [], false, "", now);
|
const model = buildHistoryViewModel(entries, "deleted", "", ["today", "week"], [], false, "", now);
|
||||||
const calls: Array<unknown> = [];
|
const calls: Array<unknown> = [];
|
||||||
@@ -205,6 +234,83 @@ describe("history model", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HistoryView", () => {
|
describe("HistoryView", () => {
|
||||||
|
it("builds the complete page status as one localizable text value", () => {
|
||||||
|
expect(historyPageStatusLabel({ page: 2, pageSize: 100, rangeLabel: "101–200 von 250", rows: [], totalItems: 250, totalPages: 3 }))
|
||||||
|
.toBe("Seite 2 von 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only one fixed-size page with accessible previous and next controls", () => {
|
||||||
|
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
|
||||||
|
const model = {
|
||||||
|
...buildHistoryViewModel([], "all", "", [], [], false, "", now),
|
||||||
|
rows: Array.from({ length: 205 }, (_, index) => ({
|
||||||
|
...template,
|
||||||
|
id: `visible-${index + 1}`,
|
||||||
|
name: `Sichtbar ${index + 1}`
|
||||||
|
})),
|
||||||
|
totalCount: 205
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(<HistoryView actions={createActions()} model={model} />);
|
||||||
|
|
||||||
|
expect(html.match(/data-history-row-id=/g)).toHaveLength(100);
|
||||||
|
expect(html).toContain("aria-label=\"Verlaufsseiten\"");
|
||||||
|
expect(html).toContain(">Zurück<");
|
||||||
|
expect(html).toContain(">Vor<");
|
||||||
|
expect(html).toContain("100 pro Seite");
|
||||||
|
expect(html).toContain("1–100 von 205");
|
||||||
|
expect(html).toContain("Seite 1 von 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves through pages with bounded previous and next actions", () => {
|
||||||
|
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
|
||||||
|
const rows = Array.from({ length: 205 }, (_, index) => ({
|
||||||
|
...template,
|
||||||
|
id: `visible-${index + 1}`,
|
||||||
|
name: `Sichtbar ${index + 1}`
|
||||||
|
}));
|
||||||
|
const calls: number[] = [];
|
||||||
|
const first = HistoryPagination({ page: paginateHistoryRows(rows, 1), onPageChange: (page) => calls.push(page) });
|
||||||
|
const middle = HistoryPagination({ page: paginateHistoryRows(rows, 2), onPageChange: (page) => calls.push(page) });
|
||||||
|
const last = HistoryPagination({ page: paginateHistoryRows(rows, 3), onPageChange: (page) => calls.push(page) });
|
||||||
|
|
||||||
|
expect(findButton(first, "Zurück").props.disabled).toBe(true);
|
||||||
|
expect(findButton(first, "Vor").props.disabled).toBe(false);
|
||||||
|
findButton(first, "Vor").props.onClick();
|
||||||
|
findButton(middle, "Zurück").props.onClick();
|
||||||
|
findButton(middle, "Vor").props.onClick();
|
||||||
|
expect(findButton(last, "Vor").props.disabled).toBe(true);
|
||||||
|
expect(calls).toEqual([2, 1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a visible page title in the main content when the filter sidebar is unavailable", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<HistoryContent actions={createActions()} model={buildHistoryViewModel(entries, "all", "", [], [], false, "", now)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('<h1 class="history-main-title">Verlauf</h1>');
|
||||||
|
expect(html.indexOf("history-main-title")).toBeLessThan(html.indexOf("history-table"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pagination text clear of the shell information button", () => {
|
||||||
|
const css = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.history-pagination\s*\{[^}]*padding:\s*10px 14px 10px 60px;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces loading politely and errors immediately", () => {
|
||||||
|
const loading = renderToStaticMarkup(
|
||||||
|
<HistoryContent actions={createActions()} model={buildHistoryViewModel([], "all", "", [], [], true, "", now)} />
|
||||||
|
);
|
||||||
|
const error = renderToStaticMarkup(
|
||||||
|
<HistoryContent actions={createActions()} model={buildHistoryViewModel([], "all", "", [], [], false, "Verlauf konnte nicht geladen werden", now)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loading).toMatch(/role="status"[^>]*aria-live="polite"[^>]*aria-atomic="true"/);
|
||||||
|
expect(loading).toContain("Verlauf wird geladen");
|
||||||
|
expect(error).toMatch(/role="alert"[^>]*aria-live="assertive"[^>]*aria-atomic="true"/);
|
||||||
|
expect(error).toContain("Verlauf konnte nicht geladen werden");
|
||||||
|
});
|
||||||
|
|
||||||
it("marks history filters for one measured vertical selection indicator", () => {
|
it("marks history filters for one measured vertical selection indicator", () => {
|
||||||
const model = buildHistoryViewModel(entries, "week", "", [], [], false, "", now);
|
const model = buildHistoryViewModel(entries, "week", "", [], [], false, "", now);
|
||||||
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
|
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
|
||||||
@@ -288,9 +394,12 @@ describe("HistoryView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("matches the download action control and centers every header except package and file", () => {
|
it("matches the download action control and centers every header except package and file", () => {
|
||||||
const content = HistoryContent({
|
const model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now);
|
||||||
|
const content = HistoryContentPage({
|
||||||
actions: createActions(),
|
actions: createActions(),
|
||||||
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
|
model,
|
||||||
|
onPageChange: () => {},
|
||||||
|
page: paginateHistoryRows(model.rows, 1)
|
||||||
});
|
});
|
||||||
const actionCell = findElement(content, (element) => element.props.className === "history-row-action");
|
const actionCell = findElement(content, (element) => element.props.className === "history-row-action");
|
||||||
const styles = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
const styles = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8").replaceAll("\r\n", "\n");
|
||||||
@@ -326,7 +435,7 @@ describe("HistoryView", () => {
|
|||||||
onContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
onContextMenu: (id, x, y) => calls.push(["context", id, x, y])
|
||||||
});
|
});
|
||||||
const model = buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now);
|
const model = buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now);
|
||||||
const content = HistoryContent({ actions, model });
|
const content = HistoryContentPage({ actions, model, onPageChange: () => {}, page: paginateHistoryRows(model.rows, 1) });
|
||||||
|
|
||||||
const selectAll = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Alle sichtbaren Einträge auswählen");
|
const selectAll = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Alle sichtbaren Einträge auswählen");
|
||||||
selectAll.props.onChange();
|
selectAll.props.onChange();
|
||||||
@@ -353,9 +462,12 @@ describe("HistoryView", () => {
|
|||||||
it("focuses the matching row action before opening a genuine row context menu", () => {
|
it("focuses the matching row action before opening a genuine row context menu", () => {
|
||||||
const calls: Array<unknown> = [];
|
const calls: Array<unknown> = [];
|
||||||
const focusCalls: Array<unknown> = [];
|
const focusCalls: Array<unknown> = [];
|
||||||
const content = HistoryContent({
|
const model = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now);
|
||||||
|
const content = HistoryContentPage({
|
||||||
actions: createActions({ onContextMenu: (id, x, y) => calls.push([id, x, y]) }),
|
actions: createActions({ onContextMenu: (id, x, y) => calls.push([id, x, y]) }),
|
||||||
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
|
model,
|
||||||
|
onPageChange: () => {},
|
||||||
|
page: paginateHistoryRows(model.rows, 1)
|
||||||
});
|
});
|
||||||
const row = findElement(content, (element) => element.props["data-history-row-id"] === "today");
|
const row = findElement(content, (element) => element.props["data-history-row-id"] === "today");
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ describe("renderer localization", () => {
|
|||||||
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
|
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
|
||||||
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
|
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
|
||||||
expect(translateUiText("1–46 von 46", "en")).toBe("1–46 of 46");
|
expect(translateUiText("1–46 von 46", "en")).toBe("1–46 of 46");
|
||||||
|
expect(translateUiText("100.001–100.005 von 100.005", "en")).toBe("100.001–100.005 of 100.005");
|
||||||
|
expect(translateUiText("100.001–100.005 of 100.005", "de")).toBe("100.001–100.005 von 100.005");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("translates the complete history surface including status values", () => {
|
it("translates the complete history surface including status values", () => {
|
||||||
@@ -52,6 +54,31 @@ describe("renderer localization", () => {
|
|||||||
expect(translateUiText("2 pro Seite", "en")).toBe("2 per page");
|
expect(translateUiText("2 pro Seite", "en")).toBe("2 per page");
|
||||||
expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: ");
|
expect(translateUiText("Sichtbar: ", "en")).toBe("Visible: ");
|
||||||
expect(translateUiText(" pro Seite", "en")).toBe(" per page");
|
expect(translateUiText(" pro Seite", "en")).toBe(" per page");
|
||||||
|
expect(translateUiText("Soll die Sammlung Tab 2 mit 5 Link(s) wirklich entfernt werden?", "en"))
|
||||||
|
.toBe("Do you really want to remove collection Tab 2 with 5 link(s)?");
|
||||||
|
expect(translateUiText("Soll die leere Sammlung Tab 2 wirklich entfernt werden?", "en"))
|
||||||
|
.toBe("Do you really want to remove the empty collection Tab 2?");
|
||||||
|
expect(translateUiText("Link kopieren", "en")).toBe("Copy Link");
|
||||||
|
expect(translateUiText("example.test Klicken zum Kopieren", "en")).toBe("Click to copy example.test");
|
||||||
|
expect(translateUiText("Geschwindigkeit verschieben", "en")).toBe("Move Speed");
|
||||||
|
expect(translateUiText("Geschwindigkeit nach links verschieben", "en")).toBe("Move Speed left");
|
||||||
|
expect(translateUiText("Move Speed right", "de")).toBe("Geschwindigkeit nach rechts verschieben");
|
||||||
|
expect(translateUiText("Verlaufsseiten", "en")).toBe("History pages");
|
||||||
|
expect(translateUiText("Vorherige Verlaufsseite", "en")).toBe("Previous history page");
|
||||||
|
expect(translateUiText("Nächste Verlaufsseite", "en")).toBe("Next history page");
|
||||||
|
expect(translateUiText("Zurück", "en")).toBe("Back");
|
||||||
|
expect(translateUiText("Vor", "en")).toBe("Next");
|
||||||
|
expect(translateUiText("Seite 2 von 7", "en")).toBe("Page 2 of 7");
|
||||||
|
expect(translateUiText("Page 2 of 7", "de")).toBe("Seite 2 von 7");
|
||||||
|
expect(translateUiText("Seite 1.000 von 2.500", "en")).toBe("Page 1.000 of 2.500");
|
||||||
|
expect(translateUiText("Accountdaten werden aktualisiert.", "en")).toBe("Account data is being updated.");
|
||||||
|
expect(translateUiText("Keine passenden Dienste oder Zugangstypen gefunden.", "en"))
|
||||||
|
.toBe("No matching services or access types found.");
|
||||||
|
expect(translateUiText("https://example.test/a aus Sammlung A, Zeile 3 auswählen", "en"))
|
||||||
|
.toBe("Select https://example.test/a from Sammlung A, line 3");
|
||||||
|
expect(translateUiText("Select https://example.test/a from Sammlung A, line 3", "de"))
|
||||||
|
.toBe("https://example.test/a aus Sammlung A, Zeile 3 auswählen");
|
||||||
|
expect(translateUiText("abc••••\nKlicken zum Kopieren", "en")).toBe("Click to copy abc••••");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ describe("responsive shell mode", () => {
|
|||||||
expect(css).toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*width:\s*32px;[^}]*height:\s*32px;[^}]*opacity:\s*1;/s);
|
expect(css).toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*width:\s*32px;[^}]*height:\s*32px;[^}]*opacity:\s*1;/s);
|
||||||
expect(css).not.toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-scroll\s*\{[^}]*visibility:\s*visible;/s);
|
expect(css).not.toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-scroll\s*\{[^}]*visibility:\s*visible;/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the responsive expand control in the header instead of covering view content", () => {
|
||||||
|
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*top:\s*-48px;/s);
|
||||||
|
expect(css).toMatch(/\.md-shell\.has-collapsed-sidebar:is\(\.is-compact, \.is-minimum\) \.md-shell-navigation\s*\{[^}]*padding-left:\s*44px;/s);
|
||||||
|
expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.md-shell\.is-minimum \.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*top:\s*-46px;/s);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("focus restoration", () => {
|
describe("focus restoration", () => {
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ import {
|
|||||||
type AccountWorkspaceActions,
|
type AccountWorkspaceActions,
|
||||||
type AccountWorkspaceViewModel
|
type AccountWorkspaceViewModel
|
||||||
} from "../src/renderer/views/settings/AccountWorkspace";
|
} from "../src/renderer/views/settings/AccountWorkspace";
|
||||||
import { SettingsForm } from "../src/renderer/views/settings/SettingsForm";
|
import {
|
||||||
|
SettingsForm,
|
||||||
|
closeSettingsSelectAndRestoreFocus,
|
||||||
|
getSettingsSelectKeyboardAction
|
||||||
|
} from "../src/renderer/views/settings/SettingsForm";
|
||||||
import {
|
import {
|
||||||
SettingsContent,
|
SettingsContent,
|
||||||
SettingsSidebar,
|
SettingsSidebar,
|
||||||
@@ -90,6 +94,32 @@ function findElement(node: ReactNode, predicate: (element: ReactElement) => bool
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findElements(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement[] {
|
||||||
|
const results: ReactElement[] = [];
|
||||||
|
visitElements(node, (element) => {
|
||||||
|
if (predicate(element)) {
|
||||||
|
results.push(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compositeKeyboardTarget(count: number): {
|
||||||
|
elements: Array<{ closest: () => { querySelectorAll: () => unknown[] }; focus: () => void }>;
|
||||||
|
focused: () => number;
|
||||||
|
} {
|
||||||
|
let focusedIndex = -1;
|
||||||
|
const elements: Array<{ closest: () => { querySelectorAll: () => unknown[] }; focus: () => void }> = [];
|
||||||
|
const container = { querySelectorAll: () => elements };
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
elements.push({
|
||||||
|
closest: () => container,
|
||||||
|
focus: () => { focusedIndex = index; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { elements, focused: () => focusedIndex };
|
||||||
|
}
|
||||||
|
|
||||||
function count(haystack: string, needle: string): number {
|
function count(haystack: string, needle: string): number {
|
||||||
return haystack.split(needle).length - 1;
|
return haystack.split(needle).length - 1;
|
||||||
}
|
}
|
||||||
@@ -419,6 +449,14 @@ describe("settings model", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("settings views", () => {
|
describe("settings views", () => {
|
||||||
|
it("uses a dedicated high-contrast border for form controls", () => {
|
||||||
|
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||||||
|
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(theme).toContain("--ui-control-border: #707070;");
|
||||||
|
expect(theme).toContain("--ui-control-border: #7B8491;");
|
||||||
|
expect(css.match(/border:\s*1px solid var\(--ui-control-border\);/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
it("marks settings sections for one measured vertical selection indicator", () => {
|
it("marks settings sections for one measured vertical selection indicator", () => {
|
||||||
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
|
||||||
|
|
||||||
@@ -479,11 +517,25 @@ describe("settings views", () => {
|
|||||||
|
|
||||||
const source = readFileSync(new URL("../src/renderer/views/settings/SettingsForm.tsx", import.meta.url), "utf8");
|
const source = readFileSync(new URL("../src/renderer/views/settings/SettingsForm.tsx", import.meta.url), "utf8");
|
||||||
expect(source).toContain("optionRefs.current[nextIndex]?.focus()");
|
expect(source).toContain("optionRefs.current[nextIndex]?.focus()");
|
||||||
expect(source).toContain('event.key === "Home"');
|
expect(getSettingsSelectKeyboardAction("Home", 1, 3)).toEqual({ type: "focus", index: 0 });
|
||||||
expect(source).toContain('event.key === "End"');
|
expect(getSettingsSelectKeyboardAction("End", 1, 3)).toEqual({ type: "focus", index: 2 });
|
||||||
expect(source).toContain("onBlur={onBlur}");
|
expect(source).toContain("onBlur={onBlur}");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes settings selects with Escape and restores the trigger focus", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
expect(getSettingsSelectKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
|
||||||
|
expect(getSettingsSelectKeyboardAction("ArrowDown", 1, 3)).toEqual({ type: "focus", index: 2 });
|
||||||
|
expect(getSettingsSelectKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
|
||||||
|
closeSettingsSelectAndRestoreFocus(
|
||||||
|
() => calls.push("close"),
|
||||||
|
{ focus: () => calls.push("focus") }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual(["close", "focus"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears a bounded history preset when permanent retention is selected", () => {
|
it("clears a bounded history preset when permanent retention is selected", () => {
|
||||||
expect(resolveHistoryRetentionSelection("permanent", 100, "permanent")).toEqual({
|
expect(resolveHistoryRetentionSelection("permanent", 100, "permanent")).toEqual({
|
||||||
historyRetentionMode: "permanent",
|
historyRetentionMode: "permanent",
|
||||||
@@ -540,6 +592,40 @@ describe("settings views", () => {
|
|||||||
switchButton.props.onClick();
|
switchButton.props.onClick();
|
||||||
expect(changed).toBe("autoUpdate");
|
expect(changed).toBe("autoUpdate");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses roving focus and complete arrow navigation for theme radios", () => {
|
||||||
|
const changes: string[] = [];
|
||||||
|
const tree = SettingsForm({
|
||||||
|
model: formModel(),
|
||||||
|
actions: {
|
||||||
|
onChange: (id, value) => changes.push(`${id}:${String(value)}`),
|
||||||
|
onAction: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const radios = findElements(tree, (element) => element.props.role === "radio");
|
||||||
|
|
||||||
|
expect(radios.map((radio) => radio.props.tabIndex)).toEqual([-1, 0, -1]);
|
||||||
|
|
||||||
|
for (const [key, sourceIndex, targetIndex, value] of [
|
||||||
|
["ArrowRight", 1, 2, "system"],
|
||||||
|
["ArrowDown", 1, 2, "system"],
|
||||||
|
["ArrowLeft", 1, 0, "light"],
|
||||||
|
["ArrowUp", 1, 0, "light"],
|
||||||
|
["Home", 1, 0, "light"],
|
||||||
|
["End", 1, 2, "system"]
|
||||||
|
] as const) {
|
||||||
|
const target = compositeKeyboardTarget(radios.length);
|
||||||
|
let prevented = false;
|
||||||
|
radios[sourceIndex].props.onKeyDown({
|
||||||
|
key,
|
||||||
|
currentTarget: target.elements[sourceIndex],
|
||||||
|
preventDefault: () => { prevented = true; }
|
||||||
|
});
|
||||||
|
expect(prevented).toBe(true);
|
||||||
|
expect(target.focused()).toBe(targetIndex);
|
||||||
|
expect(changes.at(-1)).toBe(`theme:${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("account workspace", () => {
|
describe("account workspace", () => {
|
||||||
@@ -551,6 +637,64 @@ describe("account workspace", () => {
|
|||||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses roving focus and horizontal keyboard navigation for account tabs", () => {
|
||||||
|
const panels: string[] = [];
|
||||||
|
const tree = AccountWorkspace({
|
||||||
|
model: workspaceModel(),
|
||||||
|
actions: workspaceActions({ onPanelChange: (panel) => panels.push(panel) })
|
||||||
|
});
|
||||||
|
const tabs = findElements(tree, (element) => element.props.role === "tab");
|
||||||
|
|
||||||
|
expect(tabs.map((tab) => tab.props.tabIndex)).toEqual([0, -1]);
|
||||||
|
|
||||||
|
for (const [key, sourceIndex, targetIndex, panel] of [
|
||||||
|
["ArrowRight", 0, 1, "rules"],
|
||||||
|
["ArrowLeft", 0, 1, "rules"],
|
||||||
|
["Home", 1, 0, "overview"],
|
||||||
|
["End", 0, 1, "rules"]
|
||||||
|
] as const) {
|
||||||
|
const target = compositeKeyboardTarget(tabs.length);
|
||||||
|
let prevented = false;
|
||||||
|
tabs[sourceIndex].props.onKeyDown({
|
||||||
|
key,
|
||||||
|
currentTarget: target.elements[sourceIndex],
|
||||||
|
preventDefault: () => { prevented = true; }
|
||||||
|
});
|
||||||
|
expect(prevented).toBe(true);
|
||||||
|
expect(target.focused()).toBe(targetIndex);
|
||||||
|
expect(panels.at(-1)).toBe(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rulesTree = AccountWorkspace({
|
||||||
|
model: { ...workspaceModel(), activePanel: "rules" },
|
||||||
|
actions: workspaceActions()
|
||||||
|
});
|
||||||
|
const rulesTabs = findElements(rulesTree, (element) => element.props.role === "tab");
|
||||||
|
expect(rulesTabs.map((tab) => tab.props.tabIndex)).toEqual([-1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces account loading and errors without hiding the existing table state", () => {
|
||||||
|
const loadingHtml = renderToStaticMarkup(
|
||||||
|
<AccountWorkspace
|
||||||
|
actions={workspaceActions()}
|
||||||
|
model={{ ...workspaceModel(), busy: true, rows: [], selectedIds: [] }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const errorHtml = renderToStaticMarkup(
|
||||||
|
<AccountWorkspace
|
||||||
|
actions={workspaceActions()}
|
||||||
|
model={{ ...workspaceModel(), busy: false, error: "Accounts konnten nicht geladen werden", rows: [], selectedIds: [] }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loadingHtml).toContain('aria-busy="true"');
|
||||||
|
expect(loadingHtml).toContain('aria-live="polite"');
|
||||||
|
expect(loadingHtml).toContain('role="status"');
|
||||||
|
expect(loadingHtml).toContain("Accountdaten werden aktualisiert");
|
||||||
|
expect(errorHtml).toContain('role="alert"');
|
||||||
|
expect(errorHtml).toContain("Accounts konnten nicht geladen werden");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
|
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
|
||||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||||
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
|
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
|
||||||
@@ -717,6 +861,36 @@ describe("account workspace", () => {
|
|||||||
expect(selected).toEqual(["debridlink-api"]);
|
expect(selected).toEqual(["debridlink-api"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows an accessible empty result when account search has no matches", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AccountAddDialog
|
||||||
|
actions={{
|
||||||
|
onQueryChange: () => {},
|
||||||
|
onFilterChange: () => {},
|
||||||
|
onOptionSelect: () => {},
|
||||||
|
onFieldChange: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
onSubmit: () => {}
|
||||||
|
}}
|
||||||
|
model={{
|
||||||
|
open: true,
|
||||||
|
query: "nicht vorhanden",
|
||||||
|
filter: "all",
|
||||||
|
options: [],
|
||||||
|
selectedOptionId: null,
|
||||||
|
fields: [],
|
||||||
|
error: "",
|
||||||
|
busy: false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('aria-describedby="settings-account-picker-empty"');
|
||||||
|
expect(html).toContain('aria-live="polite"');
|
||||||
|
expect(html).toContain('role="status"');
|
||||||
|
expect(html).toContain("Keine passenden Dienste oder Zugangstypen gefunden");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps stored usernames separate from provider email addresses", () => {
|
it("keeps stored usernames separate from provider email addresses", () => {
|
||||||
const rows = projectAccountRows(accountSources(), [], NOW);
|
const rows = projectAccountRows(accountSources(), [], NOW);
|
||||||
|
|
||||||
@@ -757,6 +931,17 @@ describe("settings App integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("settings geometry", () => {
|
describe("settings geometry", () => {
|
||||||
|
it("uses central focus and semantic status text tokens", () => {
|
||||||
|
expect(settingsCss).toMatch(/\.settings-theme-option:focus-visible,[^{]*\.settings-account-picker-row:focus-visible\s*{[^}]*outline:\s*2px solid var\(--ui-focus\);/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-save-state\.is-clean,[^{]*\.settings-save-state\.is-saved\s*{[^}]*color:\s*var\(--ui-success-text\);/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-save-state\.is-dirty,[^{]*\.settings-save-state\.is-saving\s*{[^}]*color:\s*var\(--ui-warning-text\);/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-save-state\.is-error\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-account-status-badge\.is-ok\s*{[^}]*color:\s*var\(--ui-success-text\);/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-account-status-badge\.is-free,[^{]*\.settings-account-status-badge\.is-unknown\s*{[^}]*color:\s*var\(--ui-warning-text\);/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-account-status-badge\.is-invalid\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||||
|
expect(settingsCss).toMatch(/\.settings-account-table-error \.ui-data-table-empty-title,[^{]*\.settings-account-dialog-error\s*{[^}]*color:\s*var\(--ui-danger-text\);/s);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the specified form, table, switch, overflow and selection geometry", () => {
|
it("keeps the specified form, table, switch, overflow and selection geometry", () => {
|
||||||
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
|
||||||
expect(css).toMatch(/\.settings-content\s*{[^}]*padding:\s*24px;/s);
|
expect(css).toMatch(/\.settings-content\s*{[^}]*padding:\s*24px;/s);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
||||||
import { appendBandwidthSample, readBandwidthChartPalette } from "../src/renderer/App";
|
import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App";
|
||||||
import {
|
import {
|
||||||
buildStatisticsViewModel,
|
buildStatisticsViewModel,
|
||||||
type StatisticsMetric,
|
type StatisticsMetric,
|
||||||
@@ -414,14 +414,34 @@ describe("bandwidth chart palette", () => {
|
|||||||
const dark = css.match(/:root,\s*:root\[data-theme="dark"\]\s*\{([\s\S]*?)\}/)?.[1];
|
const dark = css.match(/:root,\s*:root\[data-theme="dark"\]\s*\{([\s\S]*?)\}/)?.[1];
|
||||||
const light = css.match(/:root\[data-theme="light"\]\s*\{([\s\S]*?)\}/)?.[1];
|
const light = css.match(/:root\[data-theme="light"\]\s*\{([\s\S]*?)\}/)?.[1];
|
||||||
|
|
||||||
expect(dark).toContain("--ui-speed-accent: #F2942D;");
|
expect(dark).toContain("--ui-speed-accent: #4ADE80;");
|
||||||
|
expect(dark).toContain("--ui-primary-text: #181A1F;");
|
||||||
|
expect(dark).toContain("--ui-focus: #9AB8E8;");
|
||||||
|
expect(dark).toContain("--ui-success-text: #4ADE80;");
|
||||||
|
expect(dark).toContain("--ui-warning-text: #F1C786;");
|
||||||
|
expect(dark).toContain("--ui-danger-text: #F06464;");
|
||||||
expect(dark).toContain("--ui-progress-track-text: #FFFFFF;");
|
expect(dark).toContain("--ui-progress-track-text: #FFFFFF;");
|
||||||
expect(dark).toContain("--ui-progress-fill-text: #181A1F;");
|
expect(dark).toContain("--ui-progress-fill-text: #181A1F;");
|
||||||
expect(light).toContain("--ui-speed-accent: #C2701A;");
|
expect(light).toContain("--ui-speed-accent: #1E9E55;");
|
||||||
|
expect(light).toContain("--ui-primary-text: #FFFFFF;");
|
||||||
|
expect(light).toContain("--ui-focus: #24558D;");
|
||||||
|
expect(light).toContain("--ui-success-text: #137A3D;");
|
||||||
|
expect(light).toContain("--ui-warning-text: #7A4B00;");
|
||||||
|
expect(light).toContain("--ui-danger-text: #B4232F;");
|
||||||
expect(light).toContain("--ui-progress-track-text: #181A1F;");
|
expect(light).toContain("--ui-progress-track-text: #181A1F;");
|
||||||
expect(light).toContain("--ui-progress-fill-text: #181A1F;");
|
expect(light).toContain("--ui-progress-fill-text: #181A1F;");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses theme-aware primary text and visible focus colors", () => {
|
||||||
|
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||||||
|
const shell = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||||
|
const collector = readFileSync(new URL("../src/renderer/views/collector/collector.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(theme).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--ui-focus\);/s);
|
||||||
|
expect(shell).toContain("color: var(--ui-primary-text);");
|
||||||
|
expect(collector.match(/color:\s*var\(--ui-primary-text\);/g)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
it("requests only the semantic UI color properties and keeps the computed font family", () => {
|
||||||
const requested: string[] = [];
|
const requested: string[] = [];
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
@@ -441,6 +461,46 @@ describe("bandwidth chart palette", () => {
|
|||||||
text: "rgb(145, 145, 145)",
|
text: "rgb(145, 145, 145)",
|
||||||
accent: "rgb(242, 148, 45)",
|
accent: "rgb(242, 148, 45)",
|
||||||
fontFamily: "Inter, Segoe UI, sans-serif"
|
fontFamily: "Inter, Segoe UI, sans-serif"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it("uses the semantic green speed accent for the header sparkline", () => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
const palette = readDownloadSpeedSparklinePalette((property) => {
|
||||||
|
requested.push(property);
|
||||||
|
return property === "--ui-speed-accent" ? " rgb(74, 222, 128) " : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requested).toEqual(["--ui-speed-accent"]);
|
||||||
|
expect(palette).toEqual({ accent: "rgb(74, 222, 128)" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the live chart and slows redraws when reduced motion is requested", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
|
||||||
|
|
||||||
|
expect(chartBlock).toContain('role="img"');
|
||||||
|
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
|
||||||
|
expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")');
|
||||||
|
expect(chartBlock).toContain("reducedMotion ? 1000 : 250");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for confirmation before deleting all saved download statistics", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
const actions = source.slice(source.indexOf("const statisticsActions"), source.indexOf("const collectorActions"));
|
||||||
|
|
||||||
|
expect(actions).toContain("askConfirmPrompt");
|
||||||
|
expect(actions.indexOf("askConfirmPrompt")).toBeLessThan(actions.indexOf("resetDownloadStats"));
|
||||||
|
expect(actions).toContain('title: "Gesamtstatistik zurücksetzen"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for confirmation before resetting session statistics", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
const actions = source.slice(source.indexOf("const statisticsActions"), source.indexOf("const collectorActions"));
|
||||||
|
const sessionReset = actions.slice(actions.indexOf("onResetSession"), actions.indexOf("onResetAll"));
|
||||||
|
|
||||||
|
expect(sessionReset).toContain("askConfirmPrompt");
|
||||||
|
expect(sessionReset.indexOf("askConfirmPrompt")).toBeLessThan(sessionReset.indexOf("resetSessionStats"));
|
||||||
|
expect(sessionReset).toContain('title: "Sitzungsstatistik zurücksetzen"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user