@mytec: iter9.1 ready for test
This commit is contained in:
@@ -18,6 +18,7 @@ import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
|||||||
import ToastContainer from '@/components/ui/Toast.tsx';
|
import ToastContainer from '@/components/ui/Toast.tsx';
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
import NumberInput from '@/components/ui/NumberInput.tsx';
|
||||||
|
|
||||||
const calculator = new RFCalculator();
|
const calculator = new RFCalculator();
|
||||||
|
|
||||||
@@ -363,86 +364,54 @@ export default function App() {
|
|||||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||||
Coverage Settings
|
Coverage Settings
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div>
|
<NumberInput
|
||||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
label="Radius"
|
||||||
Radius: {settings.radius} km
|
value={settings.radius}
|
||||||
</label>
|
onChange={(v) =>
|
||||||
<input
|
useCoverageStore.getState().updateSettings({ radius: v })
|
||||||
type="range"
|
}
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
value={settings.radius}
|
unit="km"
|
||||||
onChange={(e) =>
|
|
||||||
useCoverageStore
|
|
||||||
.getState()
|
|
||||||
.updateSettings({ radius: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<NumberInput
|
||||||
<div>
|
label="Resolution"
|
||||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
value={settings.resolution}
|
||||||
Resolution: {settings.resolution}m
|
onChange={(v) =>
|
||||||
</label>
|
useCoverageStore.getState().updateSettings({ resolution: v })
|
||||||
<input
|
}
|
||||||
type="range"
|
|
||||||
min={50}
|
min={50}
|
||||||
max={500}
|
max={500}
|
||||||
step={50}
|
step={50}
|
||||||
value={settings.resolution}
|
unit="m"
|
||||||
onChange={(e) =>
|
|
||||||
useCoverageStore
|
|
||||||
.getState()
|
|
||||||
.updateSettings({ resolution: Number(e.target.value) })
|
|
||||||
}
|
|
||||||
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<NumberInput
|
||||||
<div>
|
label="Min Signal"
|
||||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
|
||||||
Min Signal: {settings.rsrpThreshold} dBm
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={-140}
|
|
||||||
max={-70}
|
|
||||||
step={5}
|
|
||||||
value={settings.rsrpThreshold}
|
value={settings.rsrpThreshold}
|
||||||
onChange={(e) =>
|
onChange={(v) =>
|
||||||
useCoverageStore
|
useCoverageStore.getState().updateSettings({ rsrpThreshold: v })
|
||||||
.getState()
|
|
||||||
.updateSettings({
|
|
||||||
rsrpThreshold: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
min={-140}
|
||||||
|
max={-50}
|
||||||
|
step={5}
|
||||||
|
unit="dBm"
|
||||||
/>
|
/>
|
||||||
</div>
|
<NumberInput
|
||||||
<div>
|
label="Heatmap Opacity"
|
||||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
value={Math.round(settings.heatmapOpacity * 100)}
|
||||||
Heatmap Opacity: {Math.round(settings.heatmapOpacity * 100)}%
|
onChange={(v) =>
|
||||||
</label>
|
useCoverageStore.getState().updateSettings({ heatmapOpacity: v / 100 })
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0.3}
|
|
||||||
max={1.0}
|
|
||||||
step={0.1}
|
|
||||||
value={settings.heatmapOpacity}
|
|
||||||
onChange={(e) =>
|
|
||||||
useCoverageStore
|
|
||||||
.getState()
|
|
||||||
.updateSettings({
|
|
||||||
heatmapOpacity: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
min={30}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
unit="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
||||||
Heatmap Quality: {settings.heatmapRadius}m
|
Heatmap Quality
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={settings.heatmapRadius}
|
value={settings.heatmapRadius}
|
||||||
@@ -453,7 +422,7 @@ export default function App() {
|
|||||||
heatmapRadius: Number(e.target.value),
|
heatmapRadius: Number(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full mt-1 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-200 dark:border-dark-border rounded text-gray-700 dark:text-dark-text"
|
className="w-full mt-1 px-2 py-1.5 text-sm bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded-md text-gray-700 dark:text-dark-text"
|
||||||
>
|
>
|
||||||
<option value={200}>200m — Fast</option>
|
<option value={200}>200m — Fast</option>
|
||||||
<option value={400}>400m — Balanced</option>
|
<option value={400}>400m — Balanced</option>
|
||||||
@@ -462,28 +431,22 @@ export default function App() {
|
|||||||
</select>
|
</select>
|
||||||
{settings.heatmapRadius >= 600 && settings.resolution > 200 && (
|
{settings.heatmapRadius >= 600 && settings.resolution > 200 && (
|
||||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||||
⚠ Wide radius works best with ≤200m resolution. Current: {settings.resolution}m
|
Wide radius works best with fine resolution (200m or less). Current: {settings.resolution}m
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<NumberInput
|
||||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
label="Terrain Opacity"
|
||||||
Terrain Opacity: {Math.round(terrainOpacity * 100)}%
|
value={Math.round(terrainOpacity * 100)}
|
||||||
{!showTerrain && ' (disabled)'}
|
onChange={(v) => setTerrainOpacity(v / 100)}
|
||||||
</label>
|
min={10}
|
||||||
<input
|
max={100}
|
||||||
type="range"
|
step={5}
|
||||||
min={0.1}
|
unit="%"
|
||||||
max={1.0}
|
hint={!showTerrain ? 'Enable terrain overlay first' : undefined}
|
||||||
step={0.1}
|
|
||||||
value={terrainOpacity}
|
|
||||||
onChange={(e) => setTerrainOpacity(Number(e.target.value))}
|
|
||||||
disabled={!showTerrain}
|
|
||||||
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600 disabled:opacity-40"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map Tools */}
|
{/* Map Tools */}
|
||||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Site } from '@/types/index.ts';
|
|||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
||||||
import BatchEdit from './BatchEdit.tsx';
|
import BatchEdit from './BatchEdit.tsx';
|
||||||
|
|
||||||
interface SiteListProps {
|
interface SiteListProps {
|
||||||
@@ -58,10 +59,49 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
setTimeout(() => setFlashIds(new Set()), 700);
|
setTimeout(() => setFlashIds(new Set()), 700);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
// Delete confirmation dialog state
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
const handleDeleteConfirmed = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const { id, name } = deleteTarget;
|
||||||
|
|
||||||
|
// Snapshot the site data before deleting (for undo)
|
||||||
|
const siteData = sites.find((s) => s.id === id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
|
||||||
await deleteSite(id);
|
await deleteSite(id);
|
||||||
addToast(`"${name}" deleted`, 'info');
|
|
||||||
};
|
// Toast with undo action
|
||||||
|
if (siteData) {
|
||||||
|
addToast(`"${name}" deleted`, 'info', {
|
||||||
|
duration: 10000,
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
// Restore the deleted site
|
||||||
|
await useSitesStore.getState().addSite({
|
||||||
|
name: siteData.name,
|
||||||
|
lat: siteData.lat,
|
||||||
|
lon: siteData.lon,
|
||||||
|
height: siteData.height,
|
||||||
|
power: siteData.power,
|
||||||
|
gain: siteData.gain,
|
||||||
|
frequency: siteData.frequency,
|
||||||
|
antennaType: siteData.antennaType,
|
||||||
|
azimuth: siteData.azimuth,
|
||||||
|
beamwidth: siteData.beamwidth,
|
||||||
|
color: siteData.color,
|
||||||
|
visible: siteData.visible,
|
||||||
|
notes: siteData.notes,
|
||||||
|
equipment: siteData.equipment,
|
||||||
|
});
|
||||||
|
addToast(`"${name}" restored`, 'success');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [deleteTarget, sites, deleteSite, addToast]);
|
||||||
|
|
||||||
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
||||||
|
|
||||||
@@ -148,7 +188,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDelete(site.id, site.name);
|
setDeleteTarget({ id: site.id, name: site.name });
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
|
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||||
title="Delete site"
|
title="Delete site"
|
||||||
@@ -245,6 +285,18 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Delete Site?"
|
||||||
|
message={`Are you sure you want to delete "${deleteTarget.name}"? This action can be undone for 10 seconds.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
danger
|
||||||
|
onConfirm={handleDeleteConfirmed}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
81
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
81
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
danger = false,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-focus the cancel button (safer default)
|
||||||
|
confirmRef.current?.focus();
|
||||||
|
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [onCancel, onConfirm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9500] bg-black/50 flex items-center justify-center"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-dark-surface rounded-lg shadow-xl p-5 max-w-sm mx-4 border border-gray-200 dark:border-dark-border"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-dark-text mb-2">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-dark-muted mb-5">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-gray-100 dark:bg-dark-border text-gray-700 dark:text-dark-text
|
||||||
|
hover:bg-gray-200 dark:hover:bg-dark-muted transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm rounded-md text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||||
|
${danger
|
||||||
|
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,22 @@ import { create } from 'zustand';
|
|||||||
|
|
||||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
|
interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToastMessage {
|
interface ToastMessage {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
type: ToastType;
|
type: ToastType;
|
||||||
|
action?: ToastAction;
|
||||||
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastStore {
|
interface ToastStore {
|
||||||
toasts: ToastMessage[];
|
toasts: ToastMessage[];
|
||||||
addToast: (text: string, type?: ToastType) => void;
|
addToast: (text: string, type?: ToastType, options?: { action?: ToastAction; duration?: number }) => void;
|
||||||
removeToast: (id: number) => void;
|
removeToast: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,16 +26,17 @@ let nextId = 0;
|
|||||||
|
|
||||||
export const useToastStore = create<ToastStore>((set) => ({
|
export const useToastStore = create<ToastStore>((set) => ({
|
||||||
toasts: [],
|
toasts: [],
|
||||||
addToast: (text, type = 'info') => {
|
addToast: (text, type = 'info', options) => {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
|
const duration = options?.duration ?? 4000;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
toasts: [...state.toasts, { id, text, type }],
|
toasts: [...state.toasts, { id, text, type, action: options?.action, duration }],
|
||||||
}));
|
}));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
toasts: state.toasts.filter((t) => t.id !== id),
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
}));
|
}));
|
||||||
}, 4000);
|
}, duration);
|
||||||
},
|
},
|
||||||
removeToast: (id) =>
|
removeToast: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -65,9 +73,20 @@ function ToastItem({ toast }: { toast: ToastMessage }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex-1">{toast.text}</span>
|
<span className="flex-1">{toast.text}</span>
|
||||||
|
{toast.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toast.action!.onClick();
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
className="px-2 py-0.5 text-xs font-semibold bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="text-white/80 hover:text-white"
|
className="text-white/80 hover:text-white ml-1"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -85,16 +85,41 @@ export function useKeyboardShortcuts({
|
|||||||
case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle
|
case 'f': // F: Fit to coverage — dispatch custom event for Map.tsx to handle
|
||||||
window.dispatchEvent(new CustomEvent('rfcp:fit-bounds'));
|
window.dispatchEvent(new CustomEvent('rfcp:fit-bounds'));
|
||||||
return;
|
return;
|
||||||
case 'delete': // Delete key: delete selected sites
|
case 'delete': // Delete key: delete selected site with undo
|
||||||
case 'backspace':
|
case 'backspace':
|
||||||
// Only if a site is selected (not batch)
|
|
||||||
{
|
{
|
||||||
const selectedId = useSitesStore.getState().selectedSiteId;
|
const selectedId = useSitesStore.getState().selectedSiteId;
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
const site = useSitesStore.getState().sites.find(s => s.id === selectedId);
|
const site = useSitesStore.getState().sites.find(s => s.id === selectedId);
|
||||||
if (site) {
|
if (site) {
|
||||||
|
// Snapshot for undo
|
||||||
|
const snapshot = { ...site };
|
||||||
useSitesStore.getState().deleteSite(selectedId);
|
useSitesStore.getState().deleteSite(selectedId);
|
||||||
useToastStore.getState().addToast(`"${site.name}" deleted`, 'info');
|
useToastStore.getState().addToast(`"${site.name}" deleted`, 'info', {
|
||||||
|
duration: 10000,
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
await useSitesStore.getState().addSite({
|
||||||
|
name: snapshot.name,
|
||||||
|
lat: snapshot.lat,
|
||||||
|
lon: snapshot.lon,
|
||||||
|
height: snapshot.height,
|
||||||
|
power: snapshot.power,
|
||||||
|
gain: snapshot.gain,
|
||||||
|
frequency: snapshot.frequency,
|
||||||
|
antennaType: snapshot.antennaType,
|
||||||
|
azimuth: snapshot.azimuth,
|
||||||
|
beamwidth: snapshot.beamwidth,
|
||||||
|
color: snapshot.color,
|
||||||
|
visible: snapshot.visible,
|
||||||
|
notes: snapshot.notes,
|
||||||
|
equipment: snapshot.equipment,
|
||||||
|
});
|
||||||
|
useToastStore.getState().addToast(`"${snapshot.name}" restored`, 'success');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user