diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c0c12c9..3be61b1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 {idle ? "—" : value}; + } + return ( + + {num} + {unit ? {unit} : null} + + ); +} + interface BandwidthChartProps { items: Record; running: boolean; @@ -1446,6 +1471,106 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp ); }); +interface DownloadSpeedSparklineProps { + items: Record; + running: boolean; + paused: boolean; +} + +const SPARKLINE_MAX_SAMPLES = 160; + +const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ items, running, paused }: DownloadSpeedSparklineProps): ReactElement { + const canvasRef = useRef(null); + const histRef = useRef([]); + const displayRef = useRef(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 ( +
+ + {liveSpeed > 0 ? formatSpeedMbps(liveSpeed) : "0 B/s"} +
+ ); +}); + let nextCollectorId = 1; function createScheduleId(): string { @@ -4344,37 +4469,52 @@ export function App(): ReactElement { {openMenu === "hilfe" && (
- - - - - +
setOpenSubmenu("hilfe-log")} + onMouseLeave={() => setOpenSubmenu(null)} + > + + {openSubmenu === "hilfe-log" && ( +
+ + + + + +
+ )} +
+
setOpenSubmenu("hilfe-remote")} + onMouseLeave={() => setOpenSubmenu(null)} + > + + {openSubmenu === "hilfe-remote" && ( +
+ + +
+ )} +
- - - - +
setOpenSubmenu("hilfe-diagnose")} + onMouseLeave={() => setOpenSubmenu(null)} + > + + {openSubmenu === "hilfe-diagnose" && ( +
+ + +
+ )} +
+ {tab === "downloads" && ( + + )} {tab === "downloads" && ( {tab === "collector" && ( -
+

Linksammler

@@ -4811,7 +4958,7 @@ export function App(): ReactElement { {item.eyebrow} {item.label} - {item.value} + ) : (
@@ -4819,7 +4966,7 @@ export function App(): ReactElement { {item.eyebrow} {item.label} - {item.value} +
))}
@@ -5974,14 +6121,6 @@ export function App(): ReactElement { )} {updateInstallProgress && ( diff --git a/src/renderer/styles.css b/src/renderer/styles.css index ed764d9..df2d4b1 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -536,6 +536,33 @@ body, 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 { background: var(--tab-bg); border: 1px solid var(--border); @@ -579,6 +606,20 @@ body, 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 { margin: 0; font-size: 15px; @@ -2531,7 +2572,7 @@ td { height: 100%; overflow: auto; display: grid; - grid-template-columns: 1fr 1.5fr; + grid-template-columns: 1fr 1fr; grid-template-rows: auto 1fr; gap: 10px; min-height: 0; @@ -2579,10 +2620,10 @@ td { display: flex; flex-direction: column; justify-content: space-between; - gap: 12px; - min-height: 96px; + gap: 10px; + min-height: 82px; min-width: 0; - padding: 12px 14px; + padding: 11px 14px; background: var(--field); border-radius: 10px; border: 1px solid var(--border); @@ -2600,16 +2641,17 @@ td { .stat-top { display: flex; flex-direction: column; - gap: 3px; + gap: 2px; min-width: 0; } .stat-eyebrow { color: var(--muted); - font-size: 10px; + font-size: 9.5px; font-weight: 700; - letter-spacing: 0.1em; + letter-spacing: 0.14em; text-transform: uppercase; + opacity: 0.7; } .stat-item.stat-item-clickable { @@ -2623,25 +2665,44 @@ td { .stat-label { color: var(--text); - font-size: 14px; + font-size: 13px; font-weight: 600; line-height: 1.25; } .stat-value { - font-size: clamp(22px, 1.35vw, 30px); - font-weight: 700; + display: flex; + align-items: baseline; + gap: 4px; font-variant-numeric: tabular-nums; line-height: 1.1; overflow-wrap: anywhere; } -.stat-value.stat-value-compact { - font-size: clamp(18px, 1.05vw, 24px); - line-height: 1.2; +.stat-num { + font-size: clamp(22px, 1.35vw, 30px); + 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); } @@ -2718,7 +2779,7 @@ td { } .bar-fill.completed { - background: linear-gradient(90deg, #f2942d, #ff7a5c); + background: var(--accent, #f2942d); } .provider-detail {