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
@@ -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;