feat: implement copy and link

This commit is contained in:
2026-09-20 21:40:25 +02:00
parent e05d589311
commit 10b890b5ae
15 changed files with 1564 additions and 198 deletions
+5
View File
@@ -9,3 +9,8 @@ This template should help get you started developing with Tauri, React and Types
# TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances
- Dont draw geq/peq on gain if bypass
- Diplay windows on reset geq, peq
- Preset change dont reset
- Check end to end behavioour
+4 -4
View File
@@ -180,7 +180,7 @@ pub async fn store_current_configuration(
})
}
// Command `0x23` -- TODO
// Command `0x23`
#[tauri::command]
pub async fn factory_reset(
state: State<'_, AppState>,
@@ -207,7 +207,7 @@ pub async fn set_current_preset_name(
})
}
// Command `0x2A` -- TODO
// Command `0x2A`
#[tauri::command]
pub async fn copy_channel(
state: State<'_, AppState>,
@@ -238,7 +238,7 @@ pub async fn unlock_device(
})
}
// Command `0x2F` -- TODO
// Command `0x2F`
#[tauri::command]
pub async fn lock_device(
state: State<'_, AppState>,
@@ -399,7 +399,7 @@ pub async fn set_delay(
})
}
// Command `0x39` -- TODO
// Command `0x39`
#[tauri::command]
pub async fn set_input_source(
state: State<'_, AppState>,
+185
View File
@@ -84,3 +84,188 @@ export function TextIcon({
</svg>
);
}
export function SlidersVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M10 8H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M12 21V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M12 8V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M17 16H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M19 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M19 21V16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M3 14H7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M5 10V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M5 21V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
);
}
export function GainIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M3 12H7M17 12H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 4.5L17 12L7 19.5V4.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21H12L20 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21L11 13L21 8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21L11 8H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M4 5V19M12 8.5V15.5M20 11V13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function MatrixIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M3 7C11 7 11 4 21 4" />
<path d="M3 7C11 7 11 20 21 20" />
<path d="M3 17C11 17 11 12 21 12" />
<circle cx="3" cy="7" r="1.6" fill="none" />
<circle cx="3" cy="17" r="1.6" fill="none" />
<circle cx="21" cy="4" r="1.6" fill="none" />
<circle cx="21" cy="12" r="1.6" fill="none" />
<circle cx="21" cy="20" r="1.6" fill="none" />
</svg>
);
}
+42
View File
@@ -0,0 +1,42 @@
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M2 12C2 12 5.5 5 12 5C18.5 5 22 12 22 12C22 12 18.5 19 12 19C5.5 19 2 12 2 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function EyeOffIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3L21 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.6 5.2C11.05 5.07 11.52 5 12 5C18.5 5 22 12 22 12C22 12 20.7 14.6 18.3 16.5M6.2 6.2C3.5 8.1 2 12 2 12C2 12 5.5 19 12 19C13.2 19 14.3 18.8 15.3 18.4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.9 9.9C9.35 10.45 9 11.2 9 12C9 13.66 10.34 15 12 15C12.8 15 13.55 14.65 14.1 14.1"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+3 -3
View File
@@ -43,7 +43,7 @@
.choice-select__chevron {
position: absolute;
top: 0;
right: 5%;
right: 2%;
bottom: 0;
width: 4cqh;
@@ -59,7 +59,7 @@
}
.choice-select__chevron svg {
width: 100%;
height: 100%;
width: 75%;
height: auto;
display: block;
}
+4
View File
@@ -24,6 +24,7 @@ function Tab({
onRemove,
onConnect,
onDisconnect,
actions,
}: {
dsp: DSP;
active: boolean;
@@ -33,6 +34,7 @@ function Tab({
onRemove: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
actions?: React.ReactNode;
}) {
const isConnected = dsp.status === 'connected';
@@ -76,6 +78,8 @@ function Tab({
{dsp.name}
</span>
{actions}
<button
type="button"
className="tab-close"
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
import { BoltIcon, CompressorIcon, DelayIcon, GainIcon, GateIcon, InfoIcon, KnobGainIcon, LimiterIcon, MatrixIcon, SlidersVerticalIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
import ConnectionPanel from '../ConnectionSection/ConnectionSection';
import GainSection from '../GainSection/GainSection';
import CompressorSection from '../CompressorSection/CompressorSection';
@@ -23,63 +23,80 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
const [presetLoading, setPresetLoading] = useState(false);
const inputStates = dsp.state?.current_config.input_states;
const outputStates = dsp.state?.current_config.output_states;
const inputStates = dsp.state.current_config.input_states;
const outputStates = dsp.state.current_config.output_states;
const sidebarItems: SidebarItem<SidebarId>[] = [
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> },
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> },
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> },
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> },
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> },
{ id: 'ina', label: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> },
{ id: 'inb', label: `${inputStates[InputChannel.InB].name}`, icon: <TextIcon>In B</TextIcon> },
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> },
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> },
{ id: 'gain', label: 'Gain', icon: <GainIcon /> },
{ id: 'gate', label: 'Gate', icon: <GateIcon /> },
{ id: 'compressor', label: 'Compressor', icon: <CompressorIcon /> },
{ id: 'limiter', label: 'Limiter', icon: <LimiterIcon /> },
{ id: 'delay', label: 'Delay', icon: <DelayIcon /> },
{ id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
{ id: 'geq', label: 'Graphic Equalizer', icon: <SlidersVerticalIcon /> },
{
id: 'ina',
label: inputStates[InputChannel.InA].name,
icon: <TextIcon>In A</TextIcon>,
},
{
id: 'inb',
label: inputStates[InputChannel.InB].name,
icon: <TextIcon>In B</TextIcon>,
},
{
id: 'inc',
label: inputStates[InputChannel.InC].name,
icon: <TextIcon>In C</TextIcon>,
},
{
id: 'ind',
label: inputStates[InputChannel.InD].name,
icon: <TextIcon>In D</TextIcon>,
},
{
id: 'out1',
label: `${outputStates[OutputChannel.Out1].name}`,
label: outputStates[OutputChannel.Out1].name,
icon: <TextIcon>Out 1</TextIcon>,
},
{
id: 'out2',
label: `${outputStates[OutputChannel.Out2].name}`,
label: outputStates[OutputChannel.Out2].name,
icon: <TextIcon>Out 2</TextIcon>,
},
{
id: 'out3',
label: `${outputStates[OutputChannel.Out3].name}`,
label: outputStates[OutputChannel.Out3].name,
icon: <TextIcon>Out 3</TextIcon>,
},
{
id: 'out4',
label: `${outputStates[OutputChannel.Out4].name}`,
label: outputStates[OutputChannel.Out4].name,
icon: <TextIcon>Out 4</TextIcon>,
},
{
id: 'out5',
label: `${outputStates[OutputChannel.Out5].name}`,
label: outputStates[OutputChannel.Out5].name,
icon: <TextIcon>Out 5</TextIcon>,
},
{
id: 'out6',
label: `${outputStates[OutputChannel.Out6].name}`,
label: outputStates[OutputChannel.Out6].name,
icon: <TextIcon>Out 6</TextIcon>,
},
{
id: 'out7',
label: `${outputStates[OutputChannel.Out7].name}`,
label: outputStates[OutputChannel.Out7].name,
icon: <TextIcon>Out 7</TextIcon>,
},
{
id: 'out8',
label: `${outputStates[OutputChannel.Out8].name}`,
label: outputStates[OutputChannel.Out8].name,
icon: <TextIcon>Out 8</TextIcon>,
},
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <TextIcon>Out 1</TextIcon> },
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <BoltIcon /> },
];
const presets = dsp.state.presets.names
@@ -181,14 +198,12 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
return (
<MiscellaneousPanel
dsp={dsp}
onConnect={api.connect}
onDisconnect={api.disconnect}
factoryReset={api.factoryReset}
copyChannel={api.copyChannel}
setInputSource={api.setInputSource}
linkChannels={api.linkChannels}
unlockDevice={api.unlockDevice}
lockDevice={api.lockDevice}
setActiveSidebar={setActiveSidebar}
/>
);
@@ -0,0 +1,126 @@
.copy-channel-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: rgba(0, 0, 0, 0.5);
animation: copy-channel-fade-in var(--dur-base) var(--ease-standard);
}
@keyframes copy-channel-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.copy-channel-modal {
width: 100%;
max-width: 440px;
max-height: 90vh;
overflow-y: auto;
animation: copy-channel-scale-in var(--dur-base) var(--ease-standard);
}
@keyframes copy-channel-scale-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.copy-channel-modal__header {
margin-bottom: var(--space-5);
}
.copy-channel-modal__header h2 {
margin: 0;
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.copy-channel-modal__header p {
margin: var(--space-1) 0 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Source -> destination */
.copy-channel-modal__transfer {
display: flex;
align-items: flex-end;
gap: var(--space-3);
}
.copy-channel-modal__field {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.copy-channel-modal__field label {
font-size: var(--font-sm);
color: var(--text-muted);
}
.copy-channel-modal__arrow {
display: flex;
align-items: center;
justify-content: center;
height: var(--card-control-height);
color: var(--text-muted);
flex: 0 0 auto;
}
.copy-channel-modal__arrow svg {
width: var(--space-5);
height: var(--space-5);
}
.copy-channel-modal__summary {
margin: var(--space-3) 0 var(--space-5);
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Footer actions */
.copy-channel-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
@media (max-width: 600px) {
.copy-channel-modal__transfer {
flex-direction: column;
align-items: stretch;
}
.copy-channel-modal__arrow {
height: auto;
transform: rotate(90deg);
}
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react';
import { Channel, InputChannel, OutputChannel } from '../../types';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
import './CopyChannelModal.css';
const INPUT_CHANNELS: readonly string[] = Object.values(InputChannel);
const OUTPUT_CHANNELS: readonly string[] = Object.values(OutputChannel);
const ALL_CHANNELS: readonly string[] = [...INPUT_CHANNELS, ...OUTPUT_CHANNELS];
const isInputChannel = (raw: string) => INPUT_CHANNELS.includes(raw);
/** Wraps a raw enum string back into the { Input } / { Output } shape. */
const wrapChannel = (raw: string): Channel =>
isInputChannel(raw) ? { Input: raw as InputChannel } : { Output: raw as OutputChannel };
/** The channels a given source can be copied onto: same type, minus itself. */
const destinationsFor = (source: string): string[] =>
(isInputChannel(source) ? INPUT_CHANNELS : OUTPUT_CHANNELS).filter(
(channel) => channel !== source
);
function CopyChannelModal({
onClose,
copyChannel,
}: {
onClose: () => void;
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
}) {
const [source, setSource] = useState<string>(ALL_CHANNELS[0]);
const [destination, setDestination] = useState<string>(() => destinationsFor(ALL_CHANNELS[0])[0]);
const [isCopying, setIsCopying] = useState(false);
const destinationOptions = useMemo(() => destinationsFor(source), [source]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleSourceChange = (nextSource: string) => {
setSource(nextSource);
// Picking a source of the other type (or picking the current
// destination as source) invalidates the destination.
const nextDestinations = destinationsFor(nextSource);
if (!nextDestinations.includes(destination)) {
setDestination(nextDestinations[0]);
}
};
const handleCopy = async () => {
setIsCopying(true);
try {
await copyChannel(wrapChannel(source), wrapChannel(destination));
onClose();
} finally {
setIsCopying(false);
}
};
return (
<div
className="copy-channel-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<Card className="copy-channel-modal">
<div className="copy-channel-modal__header">
<h2>Copy Channel</h2>
<p>
Copy every setting of one channel onto another. Channels can only be copied onto
channels of the same type.
</p>
</div>
<div className="copy-channel-modal__transfer">
<div className="copy-channel-modal__field">
<label htmlFor="copy-channel-source">From</label>
<ChoiceSelect<string>
id="copy-channel-source"
value={source}
options={ALL_CHANNELS}
onChange={handleSourceChange}
/>
</div>
<div className="copy-channel-modal__arrow" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="copy-channel-modal__field">
<label htmlFor="copy-channel-destination">To</label>
<ChoiceSelect<string>
id="copy-channel-destination"
value={destination}
options={destinationOptions}
onChange={setDestination}
/>
</div>
</div>
<p className="copy-channel-modal__summary">
{destination} will take the settings of {source}.
<br />
This overwrites {destination}.
</p>
<div className="copy-channel-modal__actions">
<Button variant="outline" onClick={onClose} disabled={isCopying}>
Cancel
</Button>
<Button variant="outline" onClick={handleCopy} disabled={isCopying}>
{isCopying ? 'Copying...' : 'Copy'}
</Button>
</div>
</Card>
</div>
);
}
export default CopyChannelModal;
@@ -0,0 +1,211 @@
.link-channels-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: rgba(0, 0, 0, 0.5);
animation: link-channels-fade-in var(--dur-base) var(--ease-standard);
}
@keyframes link-channels-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.link-channels-modal {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: link-channels-scale-in var(--dur-base) var(--ease-standard);
}
@keyframes link-channels-scale-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.link-channels-modal__header {
margin-bottom: var(--space-4);
}
.link-channels-modal__header h2 {
margin: 0;
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.link-channels-modal__header p {
margin: var(--space-1) 0 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Mode toggle */
.link-channels-modal__mode-toggle {
display: inline-flex;
padding: var(--space-1);
margin-bottom: var(--space-5);
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
}
.link-channels-modal__mode-btn {
padding: var(--space-2) var(--space-4);
background: transparent;
color: var(--text-muted);
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard);
}
.link-channels-modal__mode-btn.is-active {
background: var(--accent-brand);
color: var(--bg-panel);
}
/* Triangular link matrix */
.link-matrix {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-5);
}
.link-matrix__corner {
width: var(--space-6);
}
.link-matrix__col-label,
.link-matrix__row-label {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: var(--font-sm);
color: var(--text-muted);
}
.link-matrix__row-label {
justify-content: flex-start;
padding-right: var(--space-2);
}
.link-matrix__row {
display: contents;
}
.link-matrix__cell {
aspect-ratio: 1 / 1;
min-width: 32px;
border-radius: var(--radius-sm);
}
.link-matrix__cell--empty {
background: transparent;
}
.link-matrix__cell--toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
cursor: pointer;
transition:
background var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.link-matrix__cell--toggle:hover {
border-color: var(--accent-brand);
}
.link-matrix__cell--toggle.is-active {
background: var(--accent-brand);
border-color: var(--accent-brand);
cursor: default;
}
.link-matrix__cell--toggle:disabled:hover {
border-color: var(--accent-brand);
}
/* A row whose channel is itself following another channel can't also
lead others - its other tiles are locked rather than just inactive. */
.link-matrix__cell--unavailable {
background: repeating-linear-gradient(
45deg,
var(--border-hairline),
var(--border-hairline) 2px,
transparent 2px,
transparent 6px
);
border-color: var(--border-hairline);
cursor: not-allowed;
}
.link-matrix__cell--unavailable:hover {
border-color: var(--border-hairline);
}
/* Footer actions */
.link-channels-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
@media (max-width: 600px) {
.link-matrix__cell {
min-width: 26px;
}
}
@@ -0,0 +1,368 @@
import { useEffect, useState } from 'react';
import { DSP408 } from '../../../../types';
import { Channel, InputChannel, OutputChannel } from '../../types';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import './LinkChannelsModal.css';
const INPUT_CHANNELS: readonly InputChannel[] = Object.values(InputChannel);
const OUTPUT_CHANNELS: readonly OutputChannel[] = Object.values(OutputChannel);
type LinkMode = 'input' | 'output';
type LinkGroup = {
source: Channel;
destinations: Channel[];
};
/**
* Maps every channel (by its raw enum string) to the channel it currently
* follows. A channel that follows itself is unlinked. Several channels can
* follow the same leader (many-to-one), but a channel that is itself
* following someone can't also be followed by others - no chains.
*/
type LeaderMap = Record<string, string>;
const wrapInputChannel = (raw: string): Channel => ({ Input: raw as InputChannel });
const wrapOutputChannel = (raw: string): Channel => ({ Output: raw as OutputChannel });
/**
* Gets the raw enum string (InA, Out1, ...) out of a channel entry.
* The device state has been observed to store these as plain strings
* (e.g. "InA") rather than the wrapped { Input: ... } / { Output: ... }
* shape used elsewhere, so this accepts either form defensively.
*/
function channelRawId(channel: Channel | string): string {
if (typeof channel === 'string') {
return channel;
}
return 'Input' in channel ? channel.Input : channel.Output;
}
/**
* Breaks any chains that might be present in a leader map (a channel that
* both follows someone and is followed by others) by releasing whoever
* follows a follower back to themselves. Keeps the invariant - a channel
* can lead or follow, never both - true regardless of what the device
* reported.
*/
function sanitizeLeaderMap(leaderOf: LeaderMap): LeaderMap {
const next = { ...leaderOf };
Object.keys(next).forEach((channel) => {
const leader = next[channel];
if (leader !== channel && next[leader] !== leader) {
// `channel` follows a leader that itself follows someone else -
// release `channel` back to itself to break the chain.
next[channel] = channel;
}
});
return next;
}
/**
* Reads the DSP's real link state (per-channel `linked_channels: Channel[]`)
* and turns it into a leader map, so the modal opens already reflecting the
* device. Every channel defaults to following itself (unlinked); channels
* reported as linked follow whichever channel the device reported first.
*/
function extractLeaderMap<T extends string>(
states: Partial<Record<T, { linked_channels: (Channel | string)[] }>> | undefined,
orderedChannels: readonly T[]
): LeaderMap {
const leaderOf: LeaderMap = {};
orderedChannels.forEach((channel) => {
leaderOf[channel] = channel;
});
if (!states) {
return leaderOf;
}
orderedChannels.forEach((channel) => {
const state = states[channel];
if (!state || state.linked_channels.length === 0) {
return;
}
const leaderId = channelRawId(state.linked_channels[0]);
if (leaderId !== channel) {
leaderOf[channel] = leaderId;
}
});
return sanitizeLeaderMap(leaderOf);
}
/**
* Turns a leader map into one linkChannels call per leader that has
* followers (source = leader, destinations = its followers), plus one call
* per still-unlinked, follower-less channel with no destinations. Channels
* that are themselves following someone don't get their own call - they're
* already covered by their leader's call.
*/
function buildLinkCalls(
leaderOf: LeaderMap,
orderedChannels: readonly string[],
wrap: (raw: string) => Channel
): LinkGroup[] {
const followersByLeader = new Map<string, string[]>();
orderedChannels.forEach((channel) => {
const leader = leaderOf[channel];
if (leader === channel) {
return;
}
const existing = followersByLeader.get(leader) ?? [];
existing.push(channel);
followersByLeader.set(leader, existing);
});
const calls: LinkGroup[] = [];
orderedChannels.forEach((channel) => {
const followers = followersByLeader.get(channel);
if (followers) {
calls.push({ source: wrap(channel), destinations: followers.map(wrap) });
return;
}
if (leaderOf[channel] === channel) {
calls.push({ source: wrap(channel), destinations: [] });
}
});
return calls;
}
function LinkChannelsModal({
dsp,
onClose,
linkChannels,
}: {
dsp: DSP408;
onClose: () => void;
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
}) {
const [mode, setMode] = useState<LinkMode>('input');
const [inputLeaders, setInputLeaders] = useState<LeaderMap>(() =>
extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS)
);
const [outputLeaders, setOutputLeaders] = useState<LeaderMap>(() =>
extractLeaderMap(dsp.state?.current_config?.output_states, OUTPUT_CHANNELS)
);
const [isSaving, setIsSaving] = useState(false);
const channels: readonly string[] = mode === 'input' ? INPUT_CHANNELS : OUTPUT_CHANNELS;
const leaderOf = mode === 'input' ? inputLeaders : outputLeaders;
const setLeaderOf = mode === 'input' ? setInputLeaders : setOutputLeaders;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const followChannel = (follower: string, leader: string) => {
setLeaderOf((prev) => {
const next = { ...prev };
// `follower` can no longer lead anyone once it follows `leader` -
// release whoever currently follows `follower` back to themselves.
Object.keys(next).forEach((channel) => {
if (next[channel] === follower) {
next[channel] = channel;
}
});
next[follower] = leader;
return next;
});
};
const unlinkChannel = (channel: string) => {
setLeaderOf((prev) => ({ ...prev, [channel]: channel }));
};
const handleCellClick = (row: string, col: string) => {
const isSelf = row === col;
if (isSelf) {
if (leaderOf[row] !== row) {
unlinkChannel(row);
}
return;
}
if (leaderOf[row] !== row || leaderOf[col] === row) {
// Row is locked (it's itself following someone) or this pair is
// already the current state - nothing to do.
return;
}
followChannel(col, row);
};
const handleValidate = async () => {
setIsSaving(true);
try {
const groups = [
...buildLinkCalls(inputLeaders, INPUT_CHANNELS, wrapInputChannel),
...buildLinkCalls(outputLeaders, OUTPUT_CHANNELS, wrapOutputChannel),
];
for (const group of groups) {
// eslint-disable-next-line no-await-in-loop
await linkChannels(group.source, group.destinations);
}
onClose();
} finally {
setIsSaving(false);
}
};
return (
<div
className="link-channels-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<Card className="link-channels-modal">
<div className="link-channels-modal__header">
<h2>Link Channels</h2>
<p>
Link one or more channels to a single channel. A channel not linked to another is linked
to itself.
</p>
</div>
<div className="link-channels-modal__mode-toggle" role="tablist" aria-label="Channel type">
<button
type="button"
role="tab"
aria-selected={mode === 'input'}
className={`link-channels-modal__mode-btn ${mode === 'input' ? 'is-active' : ''}`}
onClick={() => setMode('input')}
>
Input
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'output'}
className={`link-channels-modal__mode-btn ${mode === 'output' ? 'is-active' : ''}`}
onClick={() => setMode('output')}
>
Output
</button>
</div>
<div
className="link-matrix"
style={{ gridTemplateColumns: `auto repeat(${channels.length}, 1fr)` }}
>
<div className="link-matrix__corner" />
{channels.map((col) => (
<div className="link-matrix__col-label" key={`col-${col}`}>
{col}
</div>
))}
{channels.map((row, rowIndex) => {
const isRowLocked = leaderOf[row] !== row;
return (
<div className="link-matrix__row" key={`row-${row}`}>
<div className="link-matrix__row-label">{row}</div>
{channels.map((col, colIndex) => {
if (colIndex < rowIndex) {
return (
<div
className="link-matrix__cell link-matrix__cell--empty"
key={`cell-${row}-${col}`}
/>
);
}
const isSelf = row === col;
const isActive = isSelf ? leaderOf[row] === row : leaderOf[col] === row;
const isUnavailable = !isSelf && !isActive && isRowLocked;
const label = isSelf
? isActive
? `${row} not linked`
: `Unlink ${row}`
: isUnavailable
? `${row} is already linked to another channel`
: isActive
? `${col} linked to ${row}`
: `Link ${col} to ${row}`;
return (
<button
type="button"
key={`cell-${row}-${col}`}
className={[
'link-matrix__cell',
'link-matrix__cell--toggle',
isUnavailable && 'link-matrix__cell--unavailable',
isActive && 'is-active',
]
.filter(Boolean)
.join(' ')}
aria-pressed={isActive}
disabled={isActive || isUnavailable}
aria-label={label}
onClick={() => handleCellClick(row, col)}
/>
);
})}
</div>
);
})}
</div>
<div className="link-channels-modal__actions">
<Button variant="outline" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button variant="outline" onClick={handleValidate} disabled={isSaving}>
{isSaving ? 'Linking...' : 'Validate'}
</Button>
</div>
</Card>
</div>
);
}
export default LinkChannelsModal;
@@ -1,110 +1,260 @@
.connection-section {
.miscellaneous-section {
width: 100%;
height: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
}
.connection-panel {
width: 340px;
.miscellaneous-panel {
width: 100%;
max-width: 720px;
/* margin: 0 auto; */
}
.miscellaneous-panel__header {
margin-bottom: var(--space-6);
}
.miscellaneous-panel__title {
margin: 0;
font-size: var(--font-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.miscellaneous-panel__hint {
margin: var(--space-2) 0 0;
font-size: var(--font-sm);
color: var(--text-muted);
}
/* Sections */
.miscellaneous-panel__section {
padding-top: var(--space-5);
margin-top: var(--space-5);
border-top: var(--border-width) solid var(--border-hairline);
}
.miscellaneous-panel__section:first-of-type {
padding-top: 0;
margin-top: 0;
border-top: none;
}
.miscellaneous-panel__section-header {
margin-bottom: var(--space-4);
}
.miscellaneous-panel__section-header h2 {
margin: 0;
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.miscellaneous-panel__section-header p {
margin: var(--space-1) 0 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Fields */
.miscellaneous-panel__row {
display: flex;
gap: var(--space-3);
}
.miscellaneous-panel__field {
display: flex;
flex-direction: column;
}
gap: var(--space-2);
.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);
.miscellaneous-panel__field label {
font-size: var(--font-sm);
color: var(--text-muted);
font-family: var(--font-mono);
letter-spacing: 0.03em;
}
.connection-panel__specs {
margin: var(--space-5) 0;
.miscellaneous-panel__field input {
height: var(--card-control-height);
padding: 0 var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.connection-panel__spec {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.connection-panel__spec dt {
color: var(--text-muted);
font-size: var(--font-sm);
}
.connection-panel__spec dd {
margin: 0;
background: var(--bg-panel);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-sm);
outline: none;
appearance: none;
-webkit-appearance: none;
transition: border-color var(--dur-base) var(--ease-standard);
}
.connection-panel__mono {
.miscellaneous-panel__field input.mono {
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: var(--font-weight-normal);
}
.connection-panel__actions {
margin-top: auto;
.miscellaneous-panel__field input:focus {
border-color: var(--accent-brand);
}
.miscellaneous-panel__field input::placeholder {
color: var(--text-muted);
opacity: 0.5;
}
/* Stepper */
.miscellaneous-panel__field .choice-stepper__input {
border: none;
background: transparent;
font-family: var(--font-mono);
font-weight: var(--font-weight-normal);
text-align: left;
}
/* Actions */
.miscellaneous-panel__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-4);
}
.connection-panel__status {
/* Danger */
.miscellaneous-panel__section--danger {
padding-top: var(--space-5);
}
.miscellaneous-panel__section--danger .miscellaneous-panel__section-header p {
max-width: 560px;
}
/* Mobile */
@media (max-width: 600px) {
.miscellaneous-panel__row {
flex-direction: column;
}
.miscellaneous-panel__actions {
justify-content: flex-start;
flex-wrap: wrap;
}
}
.miscellaneous-panel {
user-select: none;
-webkit-user-select: none;
}
.miscellaneous-panel input,
.miscellaneous-panel select {
user-select: text;
-webkit-user-select: text;
}
.confirm-modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
gap: var(--space-2);
justify-content: center;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.connection-panel__led {
height: 14px;
aspect-ratio: 1 / 1;
.confirm-modal {
width: 100%;
max-width: 400px;
padding: var(--space-5);
margin: var(--space-4);
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.confirm-modal h3 {
margin: 0 0 var(--space-2);
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.confirm-modal p {
margin: 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
.confirm-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-5);
}
/* Password Input */
.miscellaneous-panel__password-wrap {
position: relative;
width: 100%;
}
.miscellaneous-panel__password-wrap input {
width: 100%;
}
.miscellaneous-panel__password-toggle {
position: absolute;
top: 50%;
right: 3%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: auto;
height: 100%;
padding: 0;
border: none;
background: transparent;
color: var(--accent-brand);
cursor: pointer;
}
.miscellaneous-panel__password-toggle:hover {
color: var(--text-muted);
}
.miscellaneous-panel__password-toggle svg {
width: auto;
height: 65%;
}
@@ -1,52 +1,76 @@
import { useState } from 'react';
import { DSP } from '../../../../types';
import { Channel, InputSource } from '../../types';
import { DSP408 } from '../../../../types';
import { Channel, InputSource, InputType, DiscreteFrequency } from '../../types';
import { SidebarId } from '../../constants';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import Led from '../../../../components/Led/Led';
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
import LinkChannelsModal from './LinkChannelsModal';
import CopyChannelModal from './CopyChannelModal';
import './MiscellaneousSection.css';
import { EyeIcon, EyeOffIcon } from '../../../../assets/icons/EyesIcons';
const INPUT_TYPES: readonly InputType[] = Object.values(InputType);
const DISCRETE_FREQUENCIES: readonly DiscreteFrequency[] = Object.values(DiscreteFrequency);
function MiscellaneousPanel({
dsp,
onConnect,
onDisconnect,
factoryReset,
copyChannel,
setInputSource,
linkChannels,
unlockDevice,
lockDevice,
setActiveSidebar,
}: {
dsp: DSP;
onConnect: () => void;
onDisconnect: () => void;
dsp: DSP408;
factoryReset: (force: boolean) => Promise<boolean>;
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
setInputSource: (inputSource: InputSource) => Promise<boolean>;
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
unlockDevice: (password: string) => Promise<boolean>;
lockDevice: (password: string) => Promise<boolean>;
setActiveSidebar: (id: SidebarId) => void;
}) {
const [isFactoryResetting, setIsFactoryResetting] = useState(false);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
const statusLabel =
dsp.status === 'connected'
? 'Connected'
: dsp.status === 'connecting'
? 'Connecting…'
: 'Disconnected';
const [inputSource, setInputSourceState] = useState<InputSource>({
type: InputType.Analog,
frequency: DiscreteFrequency.F20,
});
const handleFactoryReset = async () => {
const confirmed = window.confirm(
'Are you sure you want to factory reset this DSP? This cannot be undone.',
);
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
if (!confirmed) {
return;
}
const handleInputTypeChange = async (type: InputType) => {
const nextSource: InputSource = {
type,
frequency: type === InputType.SineWave ? inputSource.frequency : DiscreteFrequency.F20,
};
setInputSourceState(nextSource);
await setInputSource(nextSource);
};
const handleFrequencyChange = async (frequency: DiscreteFrequency) => {
const nextSource: InputSource = {
...inputSource,
frequency,
};
setInputSourceState(nextSource);
await setInputSource(nextSource);
};
const handleFactoryReset = () => {
setIsResetConfirmOpen(true);
};
const confirmFactoryReset = async () => {
setIsResetConfirmOpen(false);
setIsFactoryResetting(true);
try {
@@ -56,83 +80,173 @@ function MiscellaneousPanel({
}
};
const handleLock = async () => {
if (!password) {
return;
}
const success = await lockDevice(password);
if (success) {
setPassword('');
setActiveSidebar('overview');
}
};
return (
<div className="connection-section">
<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="connection-panel__type">{dsp.type}</div>
<div className="miscellaneous-section">
<Card className="miscellaneous-panel">
{/* Input Source */}
<section className="miscellaneous-panel__section">
<div className="miscellaneous-panel__section-header">
<h2>Input Source</h2>
<p>Select the signal source used by the DSP.</p>
</div>
<div className="connection-panel__status">
<span className="connection-panel__led">
<Led status={dsp.status} />
</span>
<div className="miscellaneous-panel__row">
<div className="miscellaneous-panel__field">
<label htmlFor="misc-input-type">Source</label>
<span
className={`status-label status-label--${dsp.status}`}
>
{statusLabel}
</span>
</div>
<ChoiceSelect<InputType>
id="misc-input-type"
value={inputSource.type}
options={INPUT_TYPES}
onChange={handleInputTypeChange}
/>
</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>
{inputSource.type === InputType.SineWave && (
<div className="miscellaneous-panel__field">
<label htmlFor="misc-frequency">Frequency</label>
<div className="connection-panel__spec">
<dt>Port</dt>
<dd className="connection-panel__mono">{dsp.port}</dd>
</div>
<ChoiceSelect<DiscreteFrequency>
id="misc-frequency"
value={inputSource.frequency}
options={DISCRETE_FREQUENCIES}
formatLabel={(frequency) => {
if (frequency === DiscreteFrequency.F31_5) {
return '31.5 Hz';
}
<div className="connection-panel__spec">
<dt>Device ID</dt>
<dd
className="connection-panel__mono"
title={String(dsp.deviceId) || undefined}
>
{dsp.deviceId || '—'}
</dd>
return `${frequency.slice(1)} Hz`;
}}
onChange={handleFrequencyChange}
/>
</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>
</section>
{/* Channel Tools */}
<section className="miscellaneous-panel__section">
<div className="miscellaneous-panel__section-header">
<h2>Channel Tools</h2>
<p>Copy settings between channels or link multiple channels.</p>
</div>
<div className="miscellaneous-panel__actions">
<Button
variant="outline"
onClick={handleFactoryReset}
disabled={
dsp.status !== 'connected' || isFactoryResetting
}
>
{isFactoryResetting ? 'Resetting…' : 'Factory Reset'}
<Button variant="outline" onClick={() => setIsCopyModalOpen(true)}>
Copy Channel
</Button>
<Button variant="outline" onClick={() => setIsLinkModalOpen(true)}>
Link Channels
</Button>
</div>
</section>
{/* Security */}
<section className="miscellaneous-panel__section">
<div className="miscellaneous-panel__section-header">
<h2>Device Security</h2>
<p>Lock access to the DSP.</p>
</div>
<div className="miscellaneous-panel__field">
<label htmlFor="misc-password">Password</label>
<div className="miscellaneous-panel__password-wrap">
<input
id="misc-password"
type={isPasswordVisible ? 'text' : 'password'}
maxLength={4}
value={password}
placeholder="Enter password"
onChange={(e) => {
const value = e.target.value;
if (/^[\x20-\x7E]*$/.test(value)) {
setPassword(value);
}
}}
/>
<button
type="button"
className="miscellaneous-panel__password-toggle"
onClick={() => setIsPasswordVisible((v) => !v)}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
aria-pressed={isPasswordVisible}
tabIndex={-1}
>
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="miscellaneous-panel__actions">
<Button variant="outline" onClick={handleLock} disabled={password.length !== 4}>
Lock Device
</Button>
</div>
</section>
{/* Factory Reset */}
<section className="miscellaneous-panel__section miscellaneous-panel__section--danger">
<div className="miscellaneous-panel__section-header">
<h2>Factory Reset</h2>
<p>Restore the DSP to its factory configuration. This cannot be undone.</p>
</div>
<div className="miscellaneous-panel__actions">
<Button variant="outline" onClick={handleFactoryReset} disabled={isFactoryResetting}>
{isFactoryResetting ? 'Resetting...' : 'Factory Reset'}
</Button>
</div>
</section>
</Card>
{isLinkModalOpen && (
<LinkChannelsModal
dsp={dsp}
onClose={() => setIsLinkModalOpen(false)}
linkChannels={linkChannels}
/>
)}
{isCopyModalOpen && (
<CopyChannelModal onClose={() => setIsCopyModalOpen(false)} copyChannel={copyChannel} />
)}
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Factory Reset DSP?</h3>
<p>
This will restore the DSP to its factory configuration. This action cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmFactoryReset}>
Factory Reset
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+9 -2
View File
@@ -183,9 +183,16 @@ export type DeviceFlags = {
is_locked: boolean;
};
export enum InputType {
Analog = 'Analog',
PinkNoise = 'PinkNoise',
WhiteNoise = 'WhiteNoise',
SineWave = 'SineWave',
}
export type InputSource = {
type: string;
frequency: string;
type: InputType;
frequency: DiscreteFrequency;
};
export type InputChannelState = {
-2
View File
@@ -52,8 +52,6 @@ export type DSP408 = DSPBase & {
export type DSP204 = DSPBase & {
type: 'DSP204';
state: null;
meters: null;
};
export type DSP = DSP408 | DSP204;