feat: responsive connection section

This commit is contained in:
2026-07-24 22:25:50 +02:00
parent 62d3b22fea
commit a157b5f198
10 changed files with 245 additions and 183 deletions
@@ -1,64 +1,107 @@
/* .connection-panel-frame {
width: 33.333%;
height: 33.333%;
box-sizing: border-box;
}
.panel {
.connection-section {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
position: relative;
justify-content: center;
align-items: center;
}
.panel-type {
.connection-panel {
width: 340px;
display: flex;
flex-direction: column;
}
.connection-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
min-width: 0;
}
.connection-panel__header-text {
flex: 1;
min-width: 0;
}
.connection-panel__title {
margin: 0;
font-size: var(--font-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-panel__type {
margin-top: var(--space-1);
font-size: 12px;
font-size: var(--font-sm);
color: var(--text-muted);
font-family: var(--font-mono);
letter-spacing: 0.03em;
}
.panel-header {
.connection-panel__specs {
margin: var(--space-5) 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.connection-panel__spec {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.panel-header-text {
min-width: 0;
flex: 1 1 auto;
.connection-panel__spec dt {
color: var(--text-muted);
font-size: var(--font-sm);
}
.panel-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.connection-panel__spec dd {
margin: 0;
font-size: 18px;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.mono {
.connection-panel__mono {
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.specs {
.connection-panel__actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
}
.connection-panel__status {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.connection-section {
position: absolute;
inset: 0;
.connection-panel__led {
height: 14px;
aspect-ratio: 1 / 1;
display: flex;
justify-content: center;
align-items: center;
} */
justify-content: center;
flex: 0 0 auto;
}
@@ -1,14 +1,10 @@
// ConnectionPanel.tsx
import { DSP } from '../../../types/types';
import Button from '../../Button/Button';
import Card from '../../Card/Card';
import Led from '../../Led/Led';
import './ConnectionSection.css';
const DESIGN_WIDTH = 340;
const DESIGN_HEIGHT = 280;
const BTN_W = 100;
const BTN_H = 40;
function ConnectionPanel({
dsp,
onConnect,
@@ -27,59 +23,58 @@ function ConnectionPanel({
return (
<div className="connection-section">
<div
className="connection-panel-frame"
style={{ '--panel-w': DESIGN_WIDTH, '--panel-h': DESIGN_HEIGHT } as React.CSSProperties}
>
<div className="card panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
<div className="panel-header">
<div className="panel-header-text">
<h1 className="panel-title" title={dsp.name}>
{dsp.name}
</h1>
<div className="panel-type">{dsp.type}</div>
</div>
<Card className="connection-panel">
<div className="connection-panel__header">
<div className="connection-panel__header-text">
<h1 className="connection-panel__title" title={dsp.name}>
{dsp.name}
</h1>
<div className="status-block">
<span className={`led led--lg led--${dsp.status}`} />
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
</div>
<div className="connection-panel__type">{dsp.type}</div>
</div>
<dl className="specs">
<div className="spec">
<dt>IP address</dt>
<dd className="mono" title={dsp.ip}>
{dsp.ip}
</dd>
</div>
<div className="connection-panel__status">
<span className="connection-panel__led">
<Led status={dsp.status} />
</span>
<div className="spec">
<dt>Port</dt>
<dd className="mono">{dsp.port}</dd>
</div>
<div className="spec">
<dt>Device ID</dt>
<dd className="mono" title={dsp.deviceId || undefined}>
{dsp.deviceId || '—'}
</dd>
</div>
</dl>
<div className="panel-actions">
{dsp.status === 'connected' ? (
<Button variant="outline" onClick={onDisconnect}>
Disconnect
</Button>
) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
</Button>
)}
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
</div>
</div>
</div>
<dl className="connection-panel__specs">
<div className="connection-panel__spec">
<dt>IP address</dt>
<dd className="connection-panel__mono" title={dsp.ip}>
{dsp.ip}
</dd>
</div>
<div className="connection-panel__spec">
<dt>Port</dt>
<dd className="connection-panel__mono">{dsp.port}</dd>
</div>
<div className="connection-panel__spec">
<dt>Device ID</dt>
<dd className="connection-panel__mono" title={dsp.deviceId || undefined}>
{dsp.deviceId || '—'}
</dd>
</div>
</dl>
<div className="connection-panel__actions">
{dsp.status === 'connected' ? (
<Button variant="outline" onClick={onDisconnect}>
Disconnect
</Button>
) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
</Button>
)}
</div>
</Card>
</div>
);
}
@@ -1,4 +1,4 @@
import VerticalSlider from '../../Slider/VerticalSlider';
import VerticalSlider from '../Slider/VerticalSlider';
import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
@@ -93,33 +93,31 @@ function GainPanel({
return (
<div className="gain-panel-frame">
<AutoScale designWidth={DESIGN_WIDTH} designHeight={DESIGN_HEIGHT}>
<div className="gain-panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
<div className="gain-panel__title">{title}</div>
<div className="gain-panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
<div className="gain-panel__title">{title}</div>
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
onChange={setGain}
/>
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
onChange={setGain}
/>
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}>
{muted ? 'Muted' : 'Mute'}
</Button>
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}>
{muted ? 'Muted' : 'Mute'}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
</AutoScale>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,186 @@
.gauge-viewport {
container-type: inline-size;
container-name: gauge;
width: 100%;
aspect-ratio: 70 / 260;
box-sizing: border-box;
}
.gauge-container {
width: 100%;
height: 100%;
padding-top: 28.571cqw; /* 20px */
padding-bottom: 14.286cqw; /* 10px */
display: flex;
flex-direction: column;
align-items: center;
transform-origin: top center;
box-sizing: border-box;
}
.gauge-track {
position: relative;
width: 100%;
height: 300cqw; /* 210px */
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 11.429cqw; /* 8px */
box-sizing: border-box;
}
.slider {
position: relative;
width: 34.286cqw; /* 24px */
height: 100%;
cursor: pointer;
touch-action: none;
user-select: none;
}
.track {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 2.857cqw; /* 2px */
height: 100%;
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.thumb {
position: absolute;
left: 50%;
width: 25.714cqw; /* 18px */
aspect-ratio: 1;
transform: translate(-50%, 50%);
border-radius: 50%;
background: var(--bg-panel);
border: var(--border-width-active) solid var(--accent-brand);
box-shadow: var(--shadow-sm);
}
.ticks {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.ticks span {
width: 14.286cqw; /* 10px */
height: 2.857cqw; /* 2px */
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.left {
left: 11.429cqw; /* 8px */
}
.right {
right: 11.429cqw; /* 8px */
}
.value {
width: 100%;
margin-top: 5.714cqw; /* 4px */
padding-top: 2.857cqw; /* 2px */
padding-bottom: 2.857cqw; /* 2px */
padding-right: 5.714cqw; /* 4px */
padding-left: 5.714cqw; /* 4px */
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
gap: 0.25em;
white-space: nowrap;
color: var(--accent-brand);
font-size: 22.857cqw;
font-family: var(--font-ui);
box-shadow: var(--shadow-sm);
border-radius: var(--radius-md);
overflow: hidden;
}
.value input {
width: auto;
min-width: 0;
height: 1.4em;
text-align: center;
background: transparent;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm);
appearance: none;
}
.value input,
.value-unit {
font-size: inherit;
line-height: 1;
font-family: inherit;
color: inherit;
}
@@ -0,0 +1,259 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import './VerticalSlider.css';
// Design size 260*70 (height*width) — kept as the reference ratio.
// All CSS dimensions are expressed in container-query width units (cqw)
// relative to this DESIGN_WIDTH, so the whole component scales together
// and preserves this aspect ratio at any rendered size.
const DESIGN_WIDTH = 70;
type VerticalSliderProps = {
min: number;
max: number;
step: number;
switchStepValue?: number;
switchStep?: number;
value?: number;
onChange?: (value: number) => void;
unit?: string;
className?: string;
style?: React.CSSProperties;
};
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
return i === -1 ? 0 : s.length - i - 1;
}
export default function VerticalSlider({
min,
max,
step,
switchStepValue,
switchStep,
value: controlledValue,
onChange,
unit = '',
className,
style,
}: VerticalSliderProps) {
const [internalValue, setInternalValue] = useState(min);
const value = controlledValue ?? internalValue;
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const lastValue = useRef(value);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const rafRef = useRef<number | null>(null);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
useEffect(() => {
if (keyboardEditing.current) return;
setDisplayValue(value);
dragValue.current = value;
}, [value]);
const currentStep =
switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue
? switchStep
: step;
const decimals = useMemo(
() => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0),
[step, switchStep]
);
const maxChars = useMemo(() => {
const minStr = min.toFixed(decimals);
const maxStr = max.toFixed(decimals);
return Math.max(minStr.length, maxStr.length) + unit.length;
}, [min, max, decimals, unit]);
// Computed in "design pixels" (i.e. assuming the component is rendered at
// DESIGN_WIDTH px wide), then converted below to cqw so it scales with
// the actual rendered size instead of staying a fixed pixel value.
const valueFontSize = useMemo(() => {
const availableWidth = DESIGN_WIDTH - 8;
const AVG_CHAR_WIDTH_RATIO = 0.6;
const sizeByWidth = availableWidth / (maxChars * AVG_CHAR_WIDTH_RATIO);
return Math.min(16, Math.max(8, sizeByWidth));
}, [maxChars]);
// Convert the "design px" font size into cqw units relative to the
// component's own width, so it scales proportionally with everything else.
const valueFontSizeCqw = useMemo(() => (valueFontSize / DESIGN_WIDTH) * 100, [valueFontSize]);
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
const snapped = Math.round(clamped / currentStep) * currentStep;
return Number(snapped.toFixed(6));
}
function updateValue(v: number) {
const next = normalizeValue(v);
dragValue.current = next;
lastValue.current = next;
if (thumbRef.current) {
const percent = (next - min) / (max - min);
thumbRef.current.style.bottom = `${percent * 100}%`;
}
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
setDisplayValue(dragValue.current);
rafRef.current = null;
});
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
keyboardEditing.current = true;
e.preventDefault();
const base = keyboardValue.current ?? value;
let next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next = normalizeValue(next);
keyboardValue.current = next;
updateValue(next); // updates displayValue only
}
function handleKeyUp() {
if (keyboardValue.current == null) return;
keyboardEditing.current = false;
const next = keyboardValue.current;
if (controlledValue === undefined) {
setInternalValue(next);
}
onChange?.(next);
keyboardValue.current = null;
}
function valueFromPointer(clientY: number) {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height;
const raw = min + percent * (max - min);
updateValue(raw);
}
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
const target = e.currentTarget;
dragging.current = true;
target.setPointerCapture(e.pointerId);
valueFromPointer(e.clientY);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
valueFromPointer(e.clientY);
}
function endDrag(e: React.PointerEvent<HTMLDivElement>) {
dragging.current = false;
const finalValue = dragValue.current;
if (controlledValue === undefined) setInternalValue(finalValue);
setDisplayValue(finalValue);
onChange?.(finalValue);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
function commitEditing() {
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
if (!Number.isNaN(parsed)) updateValue(parsed);
}
setEditingText(null);
}
const percent = (value - min) / (max - min);
return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-container">
<div className="gauge-track">
<div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
<div
className="slider"
ref={sliderRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div>
<div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
</div>
<div className="value" style={{ fontSize: `${valueFontSizeCqw}cqw` }}>
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={editingText ?? displayValue.toFixed(decimals)}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))}
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
<span className="value-unit">{unit}</span>
</div>
</div>
</div>
);
}