feat: added record qso dvk
This commit is contained in:
@@ -12,6 +12,9 @@ import {
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
@@ -132,6 +135,7 @@ interface Props {
|
||||
Section IDs are stable strings — adding new ones means adding a panel below.
|
||||
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||
type SectionId =
|
||||
| 'general'
|
||||
| 'station'
|
||||
| 'profiles'
|
||||
| 'operating'
|
||||
@@ -167,6 +171,7 @@ const TREE: TreeNode[] = [
|
||||
},
|
||||
{
|
||||
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'General', id: 'general' },
|
||||
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
|
||||
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
|
||||
@@ -184,7 +189,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
||||
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -376,6 +381,46 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
type AudioSettings = {
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||
};
|
||||
type AudioDev = { id: string; name: string; default: boolean };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||
});
|
||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||||
const setAudioField = (patch: Partial<AudioSettings>) => setAudioCfg((s) => ({ ...s, ...patch }));
|
||||
const reloadAudioDevices = () => {
|
||||
ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {});
|
||||
ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {});
|
||||
};
|
||||
// DVK voice-keyer messages (F1–F6).
|
||||
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
const [dvkErr, setDvkErr] = useState('');
|
||||
|
||||
// General behaviour prefs (machine-local, applied live via localStorage).
|
||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||
// ClubLog Country File (cty.xml) exception status.
|
||||
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
||||
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
||||
const [clubBusy, setClubBusy] = useState(false);
|
||||
const [clubErr, setClubErr] = useState('');
|
||||
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
||||
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
||||
useEffect(() => {
|
||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||||
return () => { off?.(); };
|
||||
}, []);
|
||||
|
||||
type QSLDefaults = {
|
||||
qsl_sent: string; qsl_rcvd: string;
|
||||
lotw_sent: string; lotw_rcvd: string;
|
||||
@@ -523,6 +568,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||
reloadAudioDevices();
|
||||
reloadDvk();
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -638,7 +686,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
async function save(close = true) {
|
||||
setSaving(true); setErr(''); setMsg('');
|
||||
try {
|
||||
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
||||
@@ -677,6 +725,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveWinkeyerSettings(wk as any);
|
||||
await SaveAudioSettings(audioCfg as any);
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
await SaveQSLDefaults(qslDefaults as any);
|
||||
await SaveExternalServices(extSvc as any);
|
||||
@@ -684,7 +733,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
setMsg('Settings saved.');
|
||||
onSaved();
|
||||
setTimeout(onClose, 500);
|
||||
if (close) setTimeout(onClose, 500);
|
||||
else setTimeout(() => setMsg(''), 2000);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -2465,8 +2515,266 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function AudioPanel() {
|
||||
const deviceSelect = (
|
||||
field: keyof AudioSettings,
|
||||
devices: AudioDev[],
|
||||
placeholder: string,
|
||||
) => (
|
||||
<Select
|
||||
value={(audioCfg[field] as string) || '_'}
|
||||
onValueChange={(v) => setAudioField({ [field]: v === '_' ? '' : v } as any)}
|
||||
>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— none / system default —</SelectItem>
|
||||
{devices.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<SectionHeader
|
||||
title="Audio devices & voice keyer"
|
||||
hint="Machine-local audio routing for the Digital Voice Keyer and the QSO recorder. Pick the soundcard endpoints wired to your rig. (Pure-Go WASAPI — no extra driver.)"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
|
||||
Refresh devices
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">From Radio (RX in)</Label>
|
||||
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
|
||||
<Label className="text-sm">To Radio (TX out)</Label>
|
||||
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
|
||||
<Label className="text-sm">Recording mic</Label>
|
||||
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
|
||||
<Label className="text-sm">Listening (preview)</Label>
|
||||
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||||
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} className="mt-0.5" />
|
||||
<span>
|
||||
Record every QSO to an audio file
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Captures <strong>From Radio + your mic</strong> continuously into a rolling buffer; on <em>Log QSO</em> the
|
||||
file is saved from a few seconds <em>before</em> you entered the callsign through the end of the contact.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Recordings folder</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
|
||||
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
|
||||
<Button variant="outline" size="sm" className="h-8 shrink-0"
|
||||
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
|
||||
Browse…
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-sm">Pre-roll (seconds)</Label>
|
||||
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
|
||||
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
|
||||
className="h-8 w-24 font-mono" />
|
||||
<Label className="text-sm">File format</Label>
|
||||
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
|
||||
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Files are named <span className="font-mono">CALL_YYYYMMDD_HHMMSS.{audioCfg.format}</span>.
|
||||
{audioCfg.format === 'mp3' ? ' MP3 ≈ 7× smaller — handy to send to correspondents.' : ' WAV is lossless (~115 KB/min).'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1–F6)</h4>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>Press and hold</strong> Rec while you speak (release to save). Preview on <strong>Listening</strong>;
|
||||
during operation they transmit via <strong>To Radio</strong>.
|
||||
</p>
|
||||
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">PTT method</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (VOX)</SelectItem>
|
||||
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
|
||||
<SelectItem value="rts">Serial RTS</SelectItem>
|
||||
<SelectItem value="dtr">Serial DTR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{audioCfg.ptt_method !== 'none' && (
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||
Test PTT
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||
<>
|
||||
<Label className="text-sm">PTT COM port</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— select —</SelectItem>
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>CAT (OmniRig)</strong> keys TX through the rig control (sets OmniRig's Tx parameter) — needs CAT
|
||||
connected. <strong>Serial RTS/DTR</strong> asserts a COM line (e.g. a SmartSDR CAT port set to PTT-on-RTS).
|
||||
<strong> None (VOX)</strong> lets the rig key on audio. Use <strong>Test PTT</strong> to confirm.
|
||||
</p>
|
||||
</div>
|
||||
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
|
||||
<div className="space-y-1.5">
|
||||
{dvkMsgs.map((m) => {
|
||||
const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot;
|
||||
const recBusy = dvkStat.recording && !recHere;
|
||||
return (
|
||||
<div key={m.slot} className="flex items-center gap-2">
|
||||
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
|
||||
<Input
|
||||
className="h-8 flex-1"
|
||||
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
|
||||
value={m.label}
|
||||
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
|
||||
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
|
||||
/>
|
||||
<span className="w-16 text-[11px] text-muted-foreground text-right">
|
||||
{m.has_audio ? `✓ ${m.duration_sec.toFixed(1)}s` : '—'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant={recHere ? 'destructive' : 'outline'} size="sm" className="h-8 w-28 shrink-0 select-none touch-none"
|
||||
disabled={recBusy}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDvkErr('');
|
||||
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
|
||||
}}
|
||||
>
|
||||
{recHere ? '● Recording…' : '● Hold to rec'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline" size="sm" className="h-8 w-20 shrink-0"
|
||||
disabled={!m.has_audio || dvkStat.recording}
|
||||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
|
||||
>
|
||||
{dvkStat.playing ? '■ Stop' : '▶ Play'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralPanel() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={autofocusWB}
|
||||
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Auto-focus "Worked before" for stations already worked
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={clubInfo.enabled}
|
||||
onCheckedChange={async (c) => {
|
||||
const v = !!c; setClubInfo((s) => ({ ...s, enabled: v })); setClubErr('');
|
||||
try {
|
||||
await SetClublogCtyEnabled(v);
|
||||
let info = (await GetClublogCtyInfo()) as ClubInfo;
|
||||
// First enable with no cached data → download it now.
|
||||
if (v && !info.loaded) {
|
||||
setClubBusy(true);
|
||||
try { info = (await DownloadClublogCty()) as ClubInfo; }
|
||||
finally { setClubBusy(false); }
|
||||
}
|
||||
setClubInfo(info);
|
||||
} catch (e: any) { setClubErr(String(e?.message ?? e)); }
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Use ClubLog Country File for callsign resolution
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Applies ClubLog's date-ranged full-callsign <strong>exceptions</strong> that cty.dat lacks — e.g. VK2/SP9FIH
|
||||
resolves to Lord Howe Island (not Australia) for the DXpedition dates. Used on entry, import, UDP, and the
|
||||
right-click <em>Update from ClubLog</em>.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||||
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
|
||||
{clubBusy ? 'Downloading…' : (clubInfo.loaded ? 'Update ClubLog data' : 'Download ClubLog data')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{clubInfo.loaded
|
||||
? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}`
|
||||
: 'not downloaded'}
|
||||
</span>
|
||||
</div>
|
||||
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
general: GeneralPanel,
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
operating: OperatingPanelWrapper,
|
||||
@@ -2484,7 +2792,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
rotator: RotatorPanel,
|
||||
winkeyer: WinkeyerPanel,
|
||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||
audio: () => <ComingSoon id="audio" icon={Server} />,
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -2527,7 +2835,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</Button>
|
||||
<Button variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save'}</Button>
|
||||
<Button onClick={() => save(true)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save and close'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user