feat: responsive header
This commit is contained in:
@ -10,7 +10,7 @@ import { useNotifications } from './hooks/useNotifications';
|
||||
import Notifications from './components/Notifications/Notifications';
|
||||
import { DSPState } from './types/dsp408State';
|
||||
import CreditsPage from './pages/CreditsPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import HomePage from './pages/HomePage/HomePage';
|
||||
|
||||
function App() {
|
||||
const [dsps, setDsps] = useState<DSP[]>([]);
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
.autoscale-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.autoscale-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.autoscale-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
transform-origin: top left;
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type AutoScaleProps = {
|
||||
designWidth: number;
|
||||
designHeight: number;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export default function AutoScale({
|
||||
designWidth,
|
||||
designHeight,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: AutoScaleProps) {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
|
||||
if (!width || !height) return;
|
||||
|
||||
const nextScale = Math.min(width / designWidth, height / designHeight);
|
||||
|
||||
setScale(nextScale > 0 ? nextScale : 1);
|
||||
});
|
||||
|
||||
ro.observe(el);
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, [designWidth, designHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
className={className}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: designWidth * scale,
|
||||
height: designHeight * scale,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: designWidth,
|
||||
height: designHeight,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,20 +1,23 @@
|
||||
.btn {
|
||||
width: auto;
|
||||
height: 36px;
|
||||
padding: 0 var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
border-color var(--dur-base) var(--ease-standard),
|
||||
opacity var(--dur-base) var(--ease-standard);
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
padding: 0 var(--space-4);
|
||||
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@ -22,73 +25,25 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary */
|
||||
.btn-primary {
|
||||
background: var(--accent-brand);
|
||||
color: #0d0f12;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #6f9bf2;
|
||||
}
|
||||
|
||||
/* Ghost */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-hairline);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.btn-toggle {
|
||||
width: 90%;
|
||||
height: 40px;
|
||||
margin: 8px auto;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
/* same as ghost */
|
||||
background: transparent;
|
||||
border-color: var(--border-hairline);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Active states only */
|
||||
.btn-toggle.active-warning {
|
||||
background: var(--accent-connecting);
|
||||
border-color: var(--accent-connecting);
|
||||
color: var(--bg-void);
|
||||
}
|
||||
|
||||
.btn-toggle.active-danger {
|
||||
background: var(--accent-down);
|
||||
border-color: var(--accent-down);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--border-hairline);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
@ -1,42 +1,13 @@
|
||||
// Button.tsx
|
||||
import './Button.css';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import AutoScale from '../AutoScale/AutoScale';
|
||||
|
||||
type ButtonVariant = 'primary' | 'ghost' | 'toggle' | 'outline';
|
||||
|
||||
// Design-space size per variant (same idea as DESIGN_WIDTH/HEIGHT in the slider)
|
||||
const DEFAULT_DESIGN_SIZE: Record<ButtonVariant, { width: number; height: number }> = {
|
||||
primary: { width: 90, height: 36 },
|
||||
ghost: { width: 90, height: 36 },
|
||||
toggle: { width: 90, height: 40 },
|
||||
outline: { width: 90, height: 36 },
|
||||
};
|
||||
|
||||
const MIN_FONT_SIZE = 8;
|
||||
const MAX_FONT_SIZE = 16;
|
||||
const AVG_CHAR_WIDTH_RATIO = 0.6;
|
||||
const HORIZONTAL_PADDING = 12; // reserved px inside the box, keeps text off the edges
|
||||
|
||||
function getTextLength(children: ReactNode): number {
|
||||
if (typeof children === 'string' || typeof children === 'number') {
|
||||
return String(children).length;
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.reduce((acc: number, c) => acc + getTextLength(c), 0);
|
||||
}
|
||||
return 6; // icons / non-text children: reasonable fallback estimate
|
||||
}
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
fontSize?: number; // explicit override, in design px — skips auto-calc
|
||||
designWidth?: number; // override default design size if needed
|
||||
designHeight?: number;
|
||||
scale?: boolean; // wrap in AutoScale (default true)
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
@ -44,41 +15,18 @@ export default function Button({
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
onClick,
|
||||
fontSize = 13,
|
||||
type = 'button',
|
||||
fontSize,
|
||||
designWidth,
|
||||
designHeight,
|
||||
scale = true,
|
||||
style,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const { width: defaultW, height: defaultH } = DEFAULT_DESIGN_SIZE[variant];
|
||||
const W = designWidth ?? defaultW;
|
||||
const H = designHeight ?? defaultH;
|
||||
|
||||
const computedFontSize = useMemo(() => {
|
||||
if (fontSize !== undefined) return fontSize; // user overload wins, no calc
|
||||
|
||||
const textLength = getTextLength(children);
|
||||
if (textLength === 0) return MAX_FONT_SIZE;
|
||||
|
||||
const availableWidth = W - HORIZONTAL_PADDING;
|
||||
const sizeByWidth = availableWidth / (textLength * AVG_CHAR_WIDTH_RATIO);
|
||||
|
||||
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, sizeByWidth));
|
||||
}, [fontSize, children, W]);
|
||||
|
||||
const button = (
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`btn btn-${variant} ${className}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: scale ? '100%' : W,
|
||||
height: scale ? '100%' : H,
|
||||
fontSize: `${computedFontSize}px`,
|
||||
fontSize: `${fontSize}px`,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
@ -86,12 +34,4 @@ export default function Button({
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!scale) return button;
|
||||
|
||||
return (
|
||||
<AutoScale designWidth={W} designHeight={H}>
|
||||
{button}
|
||||
</AutoScale>
|
||||
);
|
||||
}
|
||||
}
|
||||
58
src/components/Led/Led.css
Normal file
58
src/components/Led/Led.css
Normal file
@ -0,0 +1,58 @@
|
||||
/* ---------- LED status dot ---------- */
|
||||
|
||||
.led {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* remove flex: 1 and align-self: stretch entirely */
|
||||
}
|
||||
|
||||
.led-dot {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
background: var(--accent-down);
|
||||
|
||||
transition:
|
||||
width var(--dur-base) var(--ease-standard),
|
||||
height var(--dur-base) var(--ease-standard),
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
box-shadow var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.led-dot--connected {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: var(--accent-live);
|
||||
box-shadow: 0 0 6px 1px var(--accent-live);
|
||||
}
|
||||
|
||||
.led-dot--connecting {
|
||||
background: var(--accent-connecting);
|
||||
box-shadow: 0 0 6px 1px var(--accent-connecting);
|
||||
|
||||
animation: pulse 1s var(--ease-standard) infinite;
|
||||
}
|
||||
|
||||
.led-dot--disconnected {
|
||||
background: var(--accent-down);
|
||||
box-shadow: 0 0 4px 0 var(--accent-down);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
17
src/components/Led/Led.tsx
Normal file
17
src/components/Led/Led.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import "./Led.css"
|
||||
|
||||
type LedProps = {
|
||||
status?: 'connected' | 'connecting' | 'disconnected';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Led({
|
||||
status = 'disconnected',
|
||||
className = '',
|
||||
}: LedProps) {
|
||||
return (
|
||||
<span className={['led', className].filter(Boolean).join(' ')}>
|
||||
<span className={`led-dot led-dot--${status}`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
// ConnectionPanel.tsx
|
||||
import { DSP } from '../../../types/types';
|
||||
import Button from '../../Button/Button';
|
||||
import AutoScale from '../../AutoScale/AutoScale';
|
||||
import './ConnectionSection.css';
|
||||
|
||||
const DESIGN_WIDTH = 340;
|
||||
@ -32,7 +31,6 @@ function ConnectionPanel({
|
||||
className="connection-panel-frame"
|
||||
style={{ '--panel-w': DESIGN_WIDTH, '--panel-h': DESIGN_HEIGHT } as React.CSSProperties}
|
||||
>
|
||||
<AutoScale designWidth={DESIGN_WIDTH} designHeight={DESIGN_HEIGHT}>
|
||||
<div className="card panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
|
||||
<div className="panel-header">
|
||||
<div className="panel-header-text">
|
||||
@ -73,9 +71,6 @@ function ConnectionPanel({
|
||||
{dsp.status === 'connected' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
designWidth={BTN_W}
|
||||
designHeight={BTN_H}
|
||||
scale={false}
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
@ -83,9 +78,6 @@ function ConnectionPanel({
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
designWidth={BTN_W}
|
||||
designHeight={BTN_H}
|
||||
scale={false}
|
||||
onClick={onConnect}
|
||||
disabled={dsp.status === 'connecting'}
|
||||
>
|
||||
@ -94,7 +86,6 @@ function ConnectionPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AutoScale>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -5,7 +5,6 @@ import { DSP } from '../../../types/types';
|
||||
import { NotificationType } from '../../../types/types';
|
||||
import { useState } from 'react';
|
||||
import Button from '../../Button/Button';
|
||||
import AutoScale from '../../AutoScale/AutoScale';
|
||||
import ChannelGroup from '../ChannelGroup/ChannelGroup';
|
||||
import './GainSection.css';
|
||||
|
||||
|
||||
@ -1,66 +1,184 @@
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
padding: 0 var(--space-4);
|
||||
height: var(--header-height);
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-hairline);
|
||||
-webkit-app-region: drag;
|
||||
padding-inline: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.logo-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80%;
|
||||
height: 85%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.logo-slot > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.tabstrip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: var(--header-height);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
min-width: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-hairline) transparent;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-inline: var(--space-3);
|
||||
scroll-snap-type: x proximity;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
container-type: inline-size;
|
||||
container-name: tabstrip;
|
||||
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 0);
|
||||
mask-image: linear-gradient(to right, transparent, black 0);
|
||||
}
|
||||
|
||||
.tabstrip::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.tabstrip::-webkit-scrollbar-thumb {
|
||||
background: var(--border-hairline);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.tabstrip-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--header-height);
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.icon-crown,
|
||||
.icon-tab-add,
|
||||
.icon-tab-add-empty {
|
||||
aspect-ratio: 1/1;
|
||||
height: 70%;
|
||||
}
|
||||
|
||||
.tab-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
background: transparent;
|
||||
border: var(--border-width-active) solid transparent;
|
||||
}
|
||||
|
||||
.tab-action:hover {
|
||||
background: var(--bg-raised);
|
||||
border-color: var(--accent-brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.crown-action {
|
||||
height: 50%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
color: #facc15;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.crown-action:hover {
|
||||
color: #fde047;
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
height: 50%;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.tab-add-empty {
|
||||
height: 50%;
|
||||
border-radius: var(--radius-pill);
|
||||
border: var(--border-width-active) dashed var(--border-hairline);
|
||||
color: var(--text-muted);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--tab-add-empty-font);
|
||||
font-weight: 500;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
gap: var(--space-1);
|
||||
|
||||
padding-inline: var(--space-1);
|
||||
}
|
||||
|
||||
.tab-add-empty:hover {
|
||||
border-color: var(--accent-brand);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Tab */
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--tab-height);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--tab-font-size);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
border-color var(--dur-base) var(--ease-standard),
|
||||
color var(--dur-base) var(--ease-standard);
|
||||
width: var(--tab-width);
|
||||
box-sizing: border-box;
|
||||
scroll-snap-align: start;
|
||||
|
||||
flex: 0 0 25%;
|
||||
max-width: 25%;
|
||||
min-width: calc(
|
||||
(var(--space-3) * 2) +
|
||||
(var(--tab-height) * 0.5) +
|
||||
(var(--space-2) * 2) +
|
||||
4ch +
|
||||
var(--tab-close-size)
|
||||
);
|
||||
}
|
||||
|
||||
.tab span[title] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-led {
|
||||
height: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
@ -75,74 +193,59 @@
|
||||
font-size: var(--tab-close-font);
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--tab-add-size);
|
||||
height: var(--tab-add-size);
|
||||
margin-left: 6px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
color var(--dur-base) var(--ease-standard);
|
||||
.tab:hover {
|
||||
background: var(--bg-raised);
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-add-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
gap: 6px;
|
||||
height: var(--tab-add-empty-height);
|
||||
padding: 0 var(--space-3) 0 var(--space-3);
|
||||
border: 1px dashed var(--border-hairline);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--tab-add-empty-font);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
border-color var(--dur-base) var(--ease-standard),
|
||||
color var(--dur-base) var(--ease-standard);
|
||||
.tab--active {
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--accent-brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.led {
|
||||
width: var(--led-size);
|
||||
height: var(--led-size);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent-down);
|
||||
transition: background var(--dur-base) var(--ease-standard);
|
||||
.tab--dimmed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.led--lg {
|
||||
width: var(--led-size-lg);
|
||||
height: var(--led-size-lg);
|
||||
.tab-close:hover {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-tab-add,
|
||||
.icon-tab-add-empty {
|
||||
width: var(--tab-add-icon-size);
|
||||
height: var(--tab-add-icon-size);
|
||||
.tab-add:active {
|
||||
background: var(--border-hairline);
|
||||
}
|
||||
|
||||
.icon-crown {
|
||||
width: var(--crown-icon-size);
|
||||
height: var(--crown-icon-size);
|
||||
.tabstrip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabstrip[data-fade-start='true'] {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), black);
|
||||
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), black);
|
||||
}
|
||||
.tabstrip[data-fade-end='true'] {
|
||||
-webkit-mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.tabstrip[data-fade-start='true'][data-fade-end='true'] {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.tab[data-step='6'],
|
||||
.tab[data-step='8'],
|
||||
.tab[data-step='10'] {
|
||||
min-width: calc(
|
||||
(var(--space-3) * 2) +
|
||||
(var(--tab-height) * 0.5) +
|
||||
(var(--space-2) * 2) +
|
||||
4ch +
|
||||
var(--tab-close-size)
|
||||
);
|
||||
}
|
||||
@ -3,31 +3,37 @@ import { DSP, AppPage } from '../../types/types';
|
||||
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
|
||||
import { PlusIcon } from '../../assets/icons/PlusIcon';
|
||||
import { CrownIcon } from '../../assets/icons/CrownIcon';
|
||||
import { Led } from '../Led/Led';
|
||||
|
||||
import './Topbar.css';
|
||||
|
||||
function getStep(count: number) {
|
||||
if (count <= 4) return 4;
|
||||
if (count <= 6) return 6;
|
||||
if (count <= 8) return 8;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function Tab({
|
||||
dsp,
|
||||
active,
|
||||
dimmed,
|
||||
onSelect,
|
||||
onRemove,
|
||||
dsp, active, dimmed, step, onSelect, onRemove,
|
||||
}: {
|
||||
dsp: DSP;
|
||||
active: boolean;
|
||||
dimmed: boolean;
|
||||
step: number;
|
||||
onSelect: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={'tab' + (active ? ' tab--active' : '') + (dimmed ? ' tab--dimmed' : '')}
|
||||
data-step={step}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className={'led led--' + dsp.status} />
|
||||
<span className="tab-name" title={dsp.name}>
|
||||
{dsp.name}
|
||||
<span className="tab-led">
|
||||
<Led status={dsp.status} />
|
||||
</span>
|
||||
<span title={dsp.name}>{dsp.name}</span>
|
||||
<span
|
||||
className="tab-close"
|
||||
role="button"
|
||||
@ -65,6 +71,40 @@ function TopBar({
|
||||
const hasTabs = dsps.length > 0;
|
||||
const tabstripRef = useRef<HTMLDivElement>(null);
|
||||
const prevCount = useRef(dsps.length);
|
||||
const step = getStep(dsps.length);
|
||||
|
||||
const updateFade = () => {
|
||||
const el = tabstripRef.current;
|
||||
if (!el) return;
|
||||
const atStart = el.scrollLeft <= 1;
|
||||
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
|
||||
el.dataset.fadeStart = String(!atStart);
|
||||
el.dataset.fadeEnd = String(!atEnd && el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabstripRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
el.scrollLeft += e.deltaY;
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateFade();
|
||||
const el = tabstripRef.current;
|
||||
el?.addEventListener('scroll', updateFade);
|
||||
window.addEventListener('resize', updateFade);
|
||||
return () => {
|
||||
el?.removeEventListener('scroll', updateFade);
|
||||
window.removeEventListener('resize', updateFade);
|
||||
};
|
||||
}, [dsps.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dsps.length > prevCount.current && tabstripRef.current) {
|
||||
@ -78,7 +118,7 @@ function TopBar({
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div
|
||||
<button
|
||||
className="logo-slot"
|
||||
onClick={() => {
|
||||
setSelect(null);
|
||||
@ -87,13 +127,14 @@ function TopBar({
|
||||
aria-label="Go to home"
|
||||
>
|
||||
<AnimatedLogo />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="tabstrip" ref={tabstripRef}>
|
||||
{dsps.map((dsp) => (
|
||||
<Tab
|
||||
key={dsp.id}
|
||||
dsp={dsp}
|
||||
step={step}
|
||||
active={selected === dsp.id && !(page == 'add-dsp')}
|
||||
dimmed={page === 'add-dsp'}
|
||||
onSelect={() => onSelect(dsp.id)}
|
||||
@ -104,28 +145,28 @@ function TopBar({
|
||||
|
||||
<div className="tabstrip-actions">
|
||||
{hasTabs ? (
|
||||
<button className="tab-add" aria-label="Add DSP" onClick={onAddClick}>
|
||||
<button className="tab-add tab-action" aria-label="Add DSP" onClick={onAddClick}>
|
||||
<PlusIcon className="icon-tab-add" />
|
||||
</button>
|
||||
) : (
|
||||
<button className="tab-add-empty" onClick={onAddClick}>
|
||||
<button className="tab-add-empty tab-action" onClick={onAddClick}>
|
||||
<PlusIcon className="icon-tab-add-empty" />
|
||||
<span>Add DSP</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="crown-action"
|
||||
title="Credits & Support"
|
||||
aria-label="Credits & Support"
|
||||
onClick={() => {
|
||||
setPage('credits');
|
||||
setSelect(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="crown-action tab-action"
|
||||
title="Credits & Support"
|
||||
aria-label="Credits & Support"
|
||||
onClick={() => {
|
||||
setPage('credits');
|
||||
setSelect(null);
|
||||
}}
|
||||
>
|
||||
<CrownIcon className="icon-crown" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
0
src/pages/HomePage/HomePage.css
Normal file
0
src/pages/HomePage/HomePage.css
Normal file
@ -1,4 +1,4 @@
|
||||
import Button from '../components/Button/Button';
|
||||
import Button from '../../components/Button/Button';
|
||||
|
||||
function HomePage({
|
||||
dspCount,
|
||||
@ -49,270 +49,12 @@ button:focus-visible,
|
||||
box-shadow: inset 0 0 0 1px var(--accent-brand);
|
||||
}
|
||||
|
||||
/* ---------- Top bar ---------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
padding: 0 var(--space-4);
|
||||
height: 48px;
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-hairline);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.logo-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.logo-slot > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tabstrip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 48px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-hairline) transparent;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.crown-action {
|
||||
color: #facc15;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tabstrip::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.tabstrip::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tabstrip::-webkit-scrollbar-thumb {
|
||||
background: var(--border-hairline);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.tabstrip::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-brand);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 34px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
button {
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
border-color var(--dur-base) var(--ease-standard),
|
||||
color var(--dur-base) var(--ease-standard);
|
||||
width: 160px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-raised);
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab--active {
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--accent-brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab--active:hover {
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--accent-brand);
|
||||
}
|
||||
|
||||
.tab--dimmed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 18px), transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 18px), transparent 100%);
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Quiet icon button — used once at least one tab exists */
|
||||
.tab-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-left: 6px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
color var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Lives outside .tabstrip so it never scrolls out of view */
|
||||
.tabstrip-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.tab-add:hover {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-add:active {
|
||||
background: var(--border-hairline);
|
||||
}
|
||||
|
||||
/* Clearer CTA — used when the bar has nothing else to anchor to */
|
||||
.tab-add-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 var(--space-3) 0 10px;
|
||||
border: 1px dashed var(--border-hairline);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
border-color var(--dur-base) var(--ease-standard),
|
||||
color var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.tab-add-empty svg,
|
||||
.tab-add svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-add-empty:hover {
|
||||
background: var(--bg-raised);
|
||||
border-color: var(--accent-brand);
|
||||
border-style: solid;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---------- LED status dot ---------- */
|
||||
|
||||
.led {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent-down);
|
||||
transition: background var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.led--connected {
|
||||
background: var(--accent-live);
|
||||
box-shadow: 0 0 6px 1px var(--accent-live);
|
||||
}
|
||||
|
||||
.led--connecting {
|
||||
background: var(--accent-connecting);
|
||||
box-shadow: 0 0 6px 1px var(--accent-connecting);
|
||||
animation: pulse 1s var(--ease-standard) infinite;
|
||||
}
|
||||
|
||||
.led--disconnected {
|
||||
background: var(--accent-down);
|
||||
box-shadow: 0 0 4px 0px var(--accent-down);
|
||||
}
|
||||
|
||||
.led--lg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
color var(--dur-base) var(--ease-standard),
|
||||
transform var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* ---------- Stage / main area ---------- */
|
||||
|
||||
@ -22,13 +22,10 @@
|
||||
--tab-close-size: 1.8vh;
|
||||
--tab-close-font: 1.5vh;
|
||||
|
||||
--tab-add-size: 2.6vh;
|
||||
/* --tab-add-size: 2.6vh; */
|
||||
--tab-add-empty-height: 3vh;
|
||||
--tab-add-empty-font: 1.2vh;
|
||||
|
||||
--led-size: 0.8vh;
|
||||
--led-size-lg: 1.2vh;
|
||||
|
||||
--crown-icon-size: 2.2vh;
|
||||
--tab-add-icon-size: 1.6vh;
|
||||
--logo-size: 18vh;
|
||||
@ -68,7 +65,7 @@
|
||||
/* ---------- Radius ---------- */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-lg: 1vh;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ---------- Shadow ---------- */
|
||||
@ -76,10 +73,21 @@
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
|
||||
/* ---------- Border ---------- */
|
||||
--border-width: 1px;
|
||||
--border-width-active: 2px;
|
||||
--border-width: 0.07vh;
|
||||
--border-width-active: 0.1vh;
|
||||
|
||||
/* ---------- Animation ---------- */
|
||||
--transition-fast: 120ms;
|
||||
--transition-base: 180ms;
|
||||
|
||||
--font-xs: 1.2vh;
|
||||
--font-sm: 1.4vh;
|
||||
--font-md: 1.6vh;
|
||||
--font-lg: 1.9vh;
|
||||
--font-xl: 2.2vh;
|
||||
|
||||
--font-button: 1.5vh;
|
||||
--font-label: 1.4vh;
|
||||
--font-title: 1.8vh;
|
||||
--font-value: 1.6vh;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user