UI Phase 1: Statistik 50/50, Footer rechts, Linksammler bis Footer, Bandbreiten-Graph oben rechts, Stat-Karten + Hilfe-Untermenues

Sichtbarkeits-/Layout-Politur (User-Wunsch, Quick-Wins-Paket vor den groesseren
Reworks von Einstellungen-Tab und Account-Verwaltung):

- Statistik: Bandbreitenverlauf und Hoster-Statistik jetzt 50/50 statt 40/60
  (grid-template-columns 1fr 1fr).
- Footer: die Zaehler (Pakete/Links/Session/Gesamt/Hoster/Speed/ETA) wandern
  nach unten-RECHTS, die Aktions-Buttons (Ein-/Ausklappen, Leeren, Clipboard)
  nach links.
- Linksammler: das Textfeld fuellt jetzt die volle Hoehe bis zum Footer
  (collector-view: grid-row 1fr, textarea flex:1) statt bei 220px zu enden.
- Downloads-Tab: neuer Bandbreiten-Graph oben rechts (DownloadSpeedSparkline)
  neben der Suche. Eigener 250ms-Timer mit geglaettetem Verlauf: bei kurzem
  Einbruch auf 0 faellt die Kurve nicht hart ab, sondern gleitet weich runter
  (asymmetrisches Easing: schnell hoch 0.45, langsam runter 0.12) und
  korrigiert sich wieder; die angezeigte Zahl bleibt der echte aktuelle Wert.
  Erste Version - Optik wird nach JDownloader-Screenshot nachgezogen.
- Stat-Karten professioneller: Wert in Zahl + Einheit getrennt (Einheit
  dezent), ruhige Leerzustaende (idle '0'/'—' gedaempft statt nacktem grossen
  '0'), kompaktere Karten, dezentere Eyebrow-Labels.
- Hoster-Balken: flacher Akzent statt Neon-Verlauf (kein Gradient).
- Hilfe-Menue umstrukturiert mit Hover-Untermenues: 'Logs oeffnen' (Haupt-,
  Audit-, Rename-, Session-, Trace-Log), 'Remote-Support' (Support-Bundle +
  Support-Trace), 'Diagnose' (Debug-Setup + Debug-Token); 'Letzte Fehler
  anzeigen' und 'Suche Aktualisierungen' bleiben direkt.

Reine Renderer-Aenderung (App.tsx + styles.css). 808 Tests gruen, tsc=6
Baseline, Build ok.
This commit is contained in:
Sucukdeluxe 2026-06-15 00:18:14 +02:00
parent 73f871bfcb
commit 4c09c9a2f3
2 changed files with 261 additions and 53 deletions

View File

@ -1253,6 +1253,31 @@ function getDebridLinkKeyStatusDisplay(
}; };
} }
function splitStatValue(value: string): { num: string; unit: string; idle: boolean } {
const v = (value ?? "").trim();
if (v === "" || v === "--" || v === "—") return { num: "—", unit: "", idle: true };
const match = v.match(/^(-?[\d.,]+)\s*(.*)$/);
if (!match) return { num: v, unit: "", idle: false };
const num = match[1];
const unit = (match[2] || "").trim();
const idle = /^0([.,]0+)?$/.test(num);
return { num, unit, idle };
}
function StatValueView({ value, compact, danger }: { value: string; compact?: boolean; danger?: boolean }): ReactElement {
const { num, unit, idle } = splitStatValue(value);
const cls = `stat-value${compact ? " stat-value-compact" : ""}${danger ? " danger" : ""}${idle ? " stat-idle" : ""}`;
if (compact) {
return <span className={cls}>{idle ? "—" : value}</span>;
}
return (
<span className={cls}>
<span className="stat-num">{num}</span>
{unit ? <span className="stat-unit">{unit}</span> : null}
</span>
);
}
interface BandwidthChartProps { interface BandwidthChartProps {
items: Record<string, DownloadItem>; items: Record<string, DownloadItem>;
running: boolean; running: boolean;
@ -1446,6 +1471,106 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
); );
}); });
interface DownloadSpeedSparklineProps {
items: Record<string, DownloadItem>;
running: boolean;
paused: boolean;
}
const SPARKLINE_MAX_SAMPLES = 160;
const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused }: DownloadSpeedSparklineProps): ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const histRef = useRef<number[]>([]);
const displayRef = useRef<number>(0);
const itemsRef = useRef(items);
const activeRef = useRef(running && !paused);
const [liveSpeed, setLiveSpeed] = useState(0);
itemsRef.current = items;
activeRef.current = running && !paused;
useEffect(() => {
const draw = (): void => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
if (cssW <= 0 || cssH <= 0) return;
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const hist = histRef.current;
if (hist.length < 2) return;
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
const accent = isDark ? "#f2942d" : "#c2701a";
const fill = isDark ? "rgba(242, 148, 45, 0.16)" : "rgba(194, 112, 26, 0.16)";
let maxV = 0;
for (const v of hist) if (v > maxV) maxV = v;
maxV = Math.max(maxV, 1024 * 1024);
const pad = 2;
const h = cssH - pad * 2;
const step = cssW / (SPARKLINE_MAX_SAMPLES - 1);
const startIdx = SPARKLINE_MAX_SAMPLES - hist.length;
const px = (i: number): number => (startIdx + i) * step;
const py = (v: number): number => pad + h - (v / maxV) * h;
ctx.beginPath();
ctx.moveTo(px(0), py(hist[0]));
for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i]));
ctx.lineTo(px(hist.length - 1), pad + h);
ctx.lineTo(px(0), pad + h);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.beginPath();
ctx.moveTo(px(0), py(hist[0]));
for (let i = 1; i < hist.length; i += 1) ctx.lineTo(px(i), py(hist[i]));
ctx.strokeStyle = accent;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.stroke();
};
const tick = (): void => {
let target = 0;
if (activeRef.current) {
for (const it of Object.values(itemsRef.current)) {
if (it.status === "downloading") target += it.speedBps || 0;
}
}
const cur = displayRef.current;
const alpha = target > cur ? 0.45 : 0.12;
let next = cur + (target - cur) * alpha;
if (next < 1) next = 0;
displayRef.current = next;
const hist = histRef.current;
hist.push(next);
if (hist.length > SPARKLINE_MAX_SAMPLES) hist.splice(0, hist.length - SPARKLINE_MAX_SAMPLES);
setLiveSpeed(target);
draw();
};
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, []);
return (
<div className="speed-sparkline" title="Aktuelle Download-Geschwindigkeit (geglättet)">
<canvas ref={canvasRef} className="speed-sparkline-canvas" />
<span className="speed-sparkline-value">{liveSpeed > 0 ? formatSpeedMbps(liveSpeed) : "0 B/s"}</span>
</div>
);
});
let nextCollectorId = 1; let nextCollectorId = 1;
function createScheduleId(): string { function createScheduleId(): string {
@ -4344,37 +4469,52 @@ export function App(): ReactElement {
</button> </button>
{openMenu === "hilfe" && ( {openMenu === "hilfe" && (
<div className="menu-dropdown"> <div className="menu-dropdown">
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}> <div
<span>Log öffnen</span> className="menu-submenu"
</button> onMouseEnter={() => setOpenSubmenu("hilfe-log")}
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}> onMouseLeave={() => setOpenSubmenu(null)}
<span>Audit-Log öffnen</span> >
</button> <button className="menu-submenu-trigger">Logs öffnen</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}> {openSubmenu === "hilfe-log" && (
<span>Rename-Log öffnen</span> <div className="menu-submenu-dropdown">
</button> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button>
<span>Session-Log öffnen</span> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button>
</button> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openTraceLog().catch(() => {}); }}><span>Trace-Log</span></button>
<span>Trace-Log öffnen</span> </div>
</button> )}
</div>
<div
className="menu-submenu"
onMouseEnter={() => setOpenSubmenu("hilfe-remote")}
onMouseLeave={() => setOpenSubmenu(null)}
>
<button className="menu-submenu-trigger">Remote-Support</button>
{openSubmenu === "hilfe-remote" && (
<div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div>
)}
</div>
<div className="menu-separator" /> <div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}>
<span>Support-Bundle exportieren</span>
</button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}>
<span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span>
</button>
<button className="menu-dropdown-item" onClick={() => { void onShowRecentErrors(); }}> <button className="menu-dropdown-item" onClick={() => { void onShowRecentErrors(); }}>
<span>Letzte Fehler anzeigen</span> <span>Letzte Fehler anzeigen</span>
</button> </button>
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}> <div
<span>Debug-Setup prüfen</span> className="menu-submenu"
</button> onMouseEnter={() => setOpenSubmenu("hilfe-diagnose")}
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}> onMouseLeave={() => setOpenSubmenu(null)}
<span>Debug-Token rotieren</span> >
</button> <button className="menu-submenu-trigger">Diagnose</button>
{openSubmenu === "hilfe-diagnose" && (
<div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}><span>Debug-Setup prüfen</span></button>
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}><span>Debug-Token rotieren</span></button>
</div>
)}
</div>
<div className="menu-separator" /> <div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
<span>Suche Aktualisierungen</span> <span>Suche Aktualisierungen</span>
@ -4488,6 +4628,13 @@ export function App(): ReactElement {
<button className={tab === "history" ? "tab active" : "tab"} onClick={() => setTab("history")}>Verlauf</button> <button className={tab === "history" ? "tab active" : "tab"} onClick={() => setTab("history")}>Verlauf</button>
<button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button> <button className={tab === "statistics" ? "tab active" : "tab"} onClick={() => setTab("statistics")}>Statistiken</button>
<div className="tab-actions"> <div className="tab-actions">
{tab === "downloads" && (
<DownloadSpeedSparkline
items={snapshot.session.items}
running={snapshot.session.running}
paused={snapshot.session.paused}
/>
)}
{tab === "downloads" && ( {tab === "downloads" && (
<input <input
className="search-input tab-search" className="search-input tab-search"
@ -4502,7 +4649,7 @@ export function App(): ReactElement {
<main className="tab-content"> <main className="tab-content">
{tab === "collector" && ( {tab === "collector" && (
<section className="grid-two"> <section className="grid-two collector-view">
<article className="card wide"> <article className="card wide">
<div className="collector-header"> <div className="collector-header">
<h3>Linksammler</h3> <h3>Linksammler</h3>
@ -4811,7 +4958,7 @@ export function App(): ReactElement {
<span className="stat-eyebrow">{item.eyebrow}</span> <span className="stat-eyebrow">{item.eyebrow}</span>
<span className="stat-label">{item.label}</span> <span className="stat-label">{item.label}</span>
</span> </span>
<span className={`stat-value${item.danger ? " danger" : ""}${item.compactValue ? " stat-value-compact" : ""}`}>{item.value}</span> <StatValueView value={item.value} compact={item.compactValue} danger={item.danger} />
</button> </button>
) : ( ) : (
<div key={item.key} className="stat-item"> <div key={item.key} className="stat-item">
@ -4819,7 +4966,7 @@ export function App(): ReactElement {
<span className="stat-eyebrow">{item.eyebrow}</span> <span className="stat-eyebrow">{item.eyebrow}</span>
<span className="stat-label">{item.label}</span> <span className="stat-label">{item.label}</span>
</span> </span>
<span className={`stat-value${item.compactValue ? " stat-value-compact" : ""}`}>{item.value}</span> <StatValueView value={item.value} compact={item.compactValue} />
</div> </div>
))} ))}
</div> </div>
@ -5974,14 +6121,6 @@ export function App(): ReactElement {
)} )}
<footer className="status-bar"> <footer className="status-bar">
<span>Pakete: {snapshot.stats.totalPackages}</span>
<span>Links: {Object.keys(snapshot.session.items).length}</span>
<span>Session: {humanSize(snapshot.stats.totalDownloaded)}</span>
<span>Gesamt: {humanSize(snapshot.stats.totalDownloadedAllTime)}</span>
<span>Hoster: {providerStats.length}</span>
<span>{snapshot.speedText}</span>
<span>{snapshot.etaText}</span>
<span className="footer-spacer" />
{totalPackageCount > 0 && ( {totalPackageCount > 0 && (
<button className="btn footer-btn" title={allPackagesCollapsed ? "Alle Pakete in der Liste ausklappen und Details anzeigen" : "Alle Pakete in der Liste einklappen und nur die Kopfzeilen anzeigen"} onClick={() => { <button className="btn footer-btn" title={allPackagesCollapsed ? "Alle Pakete in der Liste ausklappen und Details anzeigen" : "Alle Pakete in der Liste einklappen und nur die Kopfzeilen anzeigen"} onClick={() => {
setCollapsedPackages((prev) => { setCollapsedPackages((prev) => {
@ -6014,6 +6153,14 @@ export function App(): ReactElement {
Clipboard: An Clipboard: An
</button> </button>
)} )}
<span className="footer-spacer" />
<span>Pakete: {snapshot.stats.totalPackages}</span>
<span>Links: {Object.keys(snapshot.session.items).length}</span>
<span>Session: {humanSize(snapshot.stats.totalDownloaded)}</span>
<span>Gesamt: {humanSize(snapshot.stats.totalDownloadedAllTime)}</span>
<span>Hoster: {providerStats.length}</span>
<span>{snapshot.speedText}</span>
<span>{snapshot.etaText}</span>
</footer> </footer>
{updateInstallProgress && ( {updateInstallProgress && (

View File

@ -536,6 +536,33 @@ body,
z-index: 1; z-index: 1;
} }
.speed-sparkline {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 10px;
background: var(--field);
border: 1px solid var(--border);
border-radius: 8px;
}
.speed-sparkline-canvas {
width: 116px;
height: 22px;
display: block;
}
.speed-sparkline-value {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
white-space: nowrap;
min-width: 66px;
text-align: right;
}
.tab { .tab {
background: var(--tab-bg); background: var(--tab-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -579,6 +606,20 @@ body,
min-height: 280px; min-height: 280px;
} }
.collector-view {
grid-template-rows: 1fr;
}
.collector-view .card.wide {
min-height: 0;
}
.collector-view .card textarea {
flex: 1;
min-height: 120px;
resize: none;
}
.card h3 { .card h3 {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
@ -2531,7 +2572,7 @@ td {
height: 100%; height: 100%;
overflow: auto; overflow: auto;
display: grid; display: grid;
grid-template-columns: 1fr 1.5fr; grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
gap: 10px; gap: 10px;
min-height: 0; min-height: 0;
@ -2579,10 +2620,10 @@ td {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
min-height: 96px; min-height: 82px;
min-width: 0; min-width: 0;
padding: 12px 14px; padding: 11px 14px;
background: var(--field); background: var(--field);
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -2600,16 +2641,17 @@ td {
.stat-top { .stat-top {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 2px;
min-width: 0; min-width: 0;
} }
.stat-eyebrow { .stat-eyebrow {
color: var(--muted); color: var(--muted);
font-size: 10px; font-size: 9.5px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
opacity: 0.7;
} }
.stat-item.stat-item-clickable { .stat-item.stat-item-clickable {
@ -2623,25 +2665,44 @@ td {
.stat-label { .stat-label {
color: var(--text); color: var(--text);
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
line-height: 1.25; line-height: 1.25;
} }
.stat-value { .stat-value {
font-size: clamp(22px, 1.35vw, 30px); display: flex;
font-weight: 700; align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.1; line-height: 1.1;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.stat-value.stat-value-compact { .stat-num {
font-size: clamp(18px, 1.05vw, 24px); font-size: clamp(22px, 1.35vw, 30px);
line-height: 1.2; font-weight: 700;
} }
.stat-value.danger { .stat-unit {
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.stat-value.stat-value-compact {
font-size: clamp(18px, 1.05vw, 24px);
font-weight: 700;
}
.stat-value.stat-idle,
.stat-value.stat-idle .stat-num {
color: var(--muted);
opacity: 0.5;
}
.stat-value.danger,
.stat-value.danger .stat-num {
color: var(--danger); color: var(--danger);
} }
@ -2718,7 +2779,7 @@ td {
} }
.bar-fill.completed { .bar-fill.completed {
background: linear-gradient(90deg, #f2942d, #ff7a5c); background: var(--accent, #f2942d);
} }
.provider-detail { .provider-detail {