diff --git a/src/components/Topbar/Topbar.css b/src/components/Topbar/Topbar.css new file mode 100644 index 0000000..1f725d6 --- /dev/null +++ b/src/components/Topbar/Topbar.css @@ -0,0 +1,264 @@ +.topbar { + display: flex; + align-items: center; + height: var(--header-height); + background: var(--bg-void); + border-bottom: 1px solid var(--border-hairline); + -webkit-app-region: drag; + padding-inline: var(--space-3); + gap: var(--space-3); + box-sizing: border-box; +} + +.logo-slot { + display: flex; + height: 85%; + aspect-ratio: 1 / 1; + + padding: 0; + border: none; + background: transparent; + + overflow: hidden; + cursor: pointer; + + -webkit-app-region: no-drag; + + border-radius: var(--radius-pill); +} + +.tabstrip { + display: flex; + height: var(--header-height); + flex: 1 1 auto; + overflow-x: auto; + overflow-y: hidden; + min-width: 0; + -webkit-app-region: no-drag; + border-left: 1px solid var(--border-hairline); + + 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-4); + padding-left: var(--space-4); + + 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-actions { + display: flex; + align-items: center; + justify-content: center; + height: var(--header-height); + flex: 0 0 auto; + gap: var(--space-3); +} + +.tab-action { + display: flex; + align-items: center; + justify-content: center; + + cursor: pointer; + padding: 0; + font: inherit; + + 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); +} + +.tab-add { + height: 70%; + border-radius: 50%; + color: var(--text-muted); + aspect-ratio: 1; +} + +.crown-action { + height: 70%; + aspect-ratio: 1; + border-radius: 50%; + color: #facc15; + width: auto; +} + +.crown-action:hover { + color: #fde047; +} + +.icon-crown, +.icon-tab-add, +.icon-tab-add-empty { + aspect-ratio: 1/1; + height: 80%; +} + +.tab-add-empty { + height: 70%; + border-radius: var(--radius-pill); + border: var(--border-width-active) dashed var(--border-hairline); + color: var(--text-muted); + + font-family: var(--font-ui); + font-weight: var(--font-weight-medium); + + flex-shrink: 0; + + gap: var(--space-2); + padding-inline: var(--space-2); +} + +.tab-add-empty:hover { + border-color: var(--accent-brand); + border-style: solid; +} + +/* Tab */ +.tab { + display: flex; + align-items: center; + gap: var(--space-3); + height: var(--tab-height); + padding: 0 var(--space-3); + border: var(--border-width-active) solid var(--border-hairline); + border-radius: var(--radius-lg); + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: var(--font-xs); + cursor: pointer; + 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:hover { + background: var(--bg-raised); + color: var(--text-primary); + border-color: var(--accent-brand); +} + +.tab-name { + flex: 1 1 auto; + min-width: 0; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + user-select: none; +} + +.tab-led { + height: 50%; + aspect-ratio: 1 / 1; + + flex: 0 0 auto; + + display: flex; + align-items: center; + justify-content: center; + + border: 0; + border-radius: var(--radius-sm); + + background: transparent; + + padding: 0; +} + +.tab-close { + height: 50%; + aspect-ratio: 1 / 1; + + flex: 0 0 auto; + + display: flex; + align-items: center; + justify-content: center; + + border: 0; + border-radius: var(--radius-sm); + + margin-left: auto; + padding: 0; + font-size: var(--tab-close-font); + color: var(--text-muted); + + background: transparent; +} + +.tab--active { + background: var(--bg-panel); + border-color: var(--accent-brand); + color: var(--text-primary); +} + +.tab--dimmed { + opacity: 0.55; +} + +.tab-close:hover { + background: var(--bg-panel); + color: var(--text-primary); +} + +.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) + ); +} diff --git a/src/components/Topbar/Topbar.tsx b/src/components/Topbar/Topbar.tsx new file mode 100644 index 0000000..57a9a52 --- /dev/null +++ b/src/components/Topbar/Topbar.tsx @@ -0,0 +1,222 @@ +import { useEffect, useRef } from 'react'; +import { DSP, AppPage } from '../../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 { CloseIcon } from '../../assets/icons/CloseIcon'; + +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, + step, + onSelect, + onRemove, + onConnect, + onDisconnect, +}: { + dsp: DSP; + active: boolean; + dimmed: boolean; + step: number; + onSelect: () => void; + onRemove: () => void; + onConnect: (dsp: DSP) => void; + onDisconnect: (dsp: DSP) => void; +}) { + const isConnected = dsp.status === 'connected'; + + const handleTabKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(); + } + }; + + return ( +
+ + + + {dsp.name} + + + +
+ ); +} + +function TopBar({ + dsps, + selected, + page, + setPage, + setSelect, + onSelect, + onRemove, + onAddClick, + onConnect, + onDisconnect, +}: { + dsps: DSP[]; + selected: number | null; + page: AppPage; + setPage: (page: AppPage) => void; + setSelect: (select: number | null) => void; + onSelect: (id: number) => void; + onRemove: (dsp: DSP) => void; + onAddClick: () => void; + onConnect: (dsp: DSP) => void; + onDisconnect: (dsp: DSP) => void; +}) { + const hasTabs = dsps.length > 0; + const tabstripRef = useRef(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) { + tabstripRef.current.scrollTo({ + left: tabstripRef.current.scrollWidth, + behavior: 'smooth', + }); + } + prevCount.current = dsps.length; + }, [dsps.length]); + + return ( +
+ + +
+ {dsps.map((dsp) => ( + onSelect(dsp.id)} + onRemove={() => onRemove(dsp)} + onConnect={onConnect} + onDisconnect={onDisconnect} + /> + ))} +
+ +
+ {hasTabs ? ( + + ) : ( + + )} + + +
+
+ ); +} + +export default TopBar;