@mytec: iter1.1.1 ready for testing
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
"Bash(tree:*)",
|
"Bash(tree:*)",
|
||||||
"Bash(python:*)",
|
"Bash(python:*)",
|
||||||
"Bash(pip --version:*)",
|
"Bash(pip --version:*)",
|
||||||
"Bash(pip install:*)"
|
"Bash(pip install:*)",
|
||||||
|
"Bash(npx vite build:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import type { Site } from '@/types/index.ts';
|
|||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
import { useCoverageStore } from '@/store/coverage.ts';
|
import { useCoverageStore } from '@/store/coverage.ts';
|
||||||
import { useSettingsStore } from '@/store/settings.ts';
|
import { useSettingsStore } from '@/store/settings.ts';
|
||||||
|
import { useHistoryStore, pushToFuture, pushToPast } from '@/store/history.ts';
|
||||||
import { RFCalculator } from '@/rf/calculator.ts';
|
import { RFCalculator } from '@/rf/calculator.ts';
|
||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
||||||
|
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges.ts';
|
||||||
import { logger } from '@/utils/logger.ts';
|
import { logger } from '@/utils/logger.ts';
|
||||||
|
import { db } from '@/db/schema.ts';
|
||||||
import MapView from '@/components/map/Map.tsx';
|
import MapView from '@/components/map/Map.tsx';
|
||||||
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
||||||
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
|
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
|
||||||
@@ -26,6 +29,26 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
|||||||
|
|
||||||
const calculator = new RFCalculator();
|
const calculator = new RFCalculator();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a sites snapshot: replace all sites in IndexedDB + Zustand.
|
||||||
|
* Used by undo/redo.
|
||||||
|
*/
|
||||||
|
async function restoreSites(snapshot: Site[]) {
|
||||||
|
// Clear current DB entries
|
||||||
|
await db.sites.clear();
|
||||||
|
// Write snapshot sites to DB
|
||||||
|
for (const site of snapshot) {
|
||||||
|
await db.sites.put({
|
||||||
|
id: site.id,
|
||||||
|
data: JSON.stringify(site),
|
||||||
|
createdAt: new Date(site.createdAt).getTime(),
|
||||||
|
updatedAt: new Date(site.updatedAt).getTime(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Reload from DB to sync Zustand
|
||||||
|
await useSitesStore.getState().loadSites();
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const loadSites = useSitesStore((s) => s.loadSites);
|
const loadSites = useSitesStore((s) => s.loadSites);
|
||||||
const sites = useSitesStore((s) => s.sites);
|
const sites = useSitesStore((s) => s.sites);
|
||||||
@@ -52,6 +75,13 @@ export default function App() {
|
|||||||
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||||
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||||
|
|
||||||
|
// History (undo/redo)
|
||||||
|
const canUndo = useHistoryStore((s) => s.canUndo);
|
||||||
|
const canRedo = useHistoryStore((s) => s.canRedo);
|
||||||
|
|
||||||
|
// Unsaved changes detection + beforeunload
|
||||||
|
useUnsavedChanges();
|
||||||
|
|
||||||
const [modalState, setModalState] = useState<{
|
const [modalState, setModalState] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
@@ -202,6 +232,49 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [kbDeleteTarget, sites, addToast]);
|
}, [kbDeleteTarget, sites, addToast]);
|
||||||
|
|
||||||
|
// === Undo / Redo ===
|
||||||
|
const handleUndo = useCallback(async () => {
|
||||||
|
const snapshot = useHistoryStore.getState().undo();
|
||||||
|
if (!snapshot) return;
|
||||||
|
|
||||||
|
// Save current state to redo stack before restoring
|
||||||
|
const currentSites = useSitesStore.getState().sites;
|
||||||
|
const currentSettings = useCoverageStore.getState().settings;
|
||||||
|
pushToFuture({
|
||||||
|
sites: structuredClone(currentSites),
|
||||||
|
settings: { ...currentSettings },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
action: 'before undo',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore sites from snapshot
|
||||||
|
await restoreSites(snapshot.sites);
|
||||||
|
useCoverageStore.getState().updateSettings(snapshot.settings);
|
||||||
|
useCoverageStore.getState().clearCoverage();
|
||||||
|
addToast('Undo', 'info');
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const handleRedo = useCallback(async () => {
|
||||||
|
const snapshot = useHistoryStore.getState().redo();
|
||||||
|
if (!snapshot) return;
|
||||||
|
|
||||||
|
// Save current state to undo stack before restoring
|
||||||
|
const currentSites = useSitesStore.getState().sites;
|
||||||
|
const currentSettings = useCoverageStore.getState().settings;
|
||||||
|
pushToPast({
|
||||||
|
sites: structuredClone(currentSites),
|
||||||
|
settings: { ...currentSettings },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
action: 'before redo',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore sites from snapshot
|
||||||
|
await restoreSites(snapshot.sites);
|
||||||
|
useCoverageStore.getState().updateSettings(snapshot.settings);
|
||||||
|
useCoverageStore.getState().clearCoverage();
|
||||||
|
addToast('Redo', 'info');
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
// Calculate coverage (with better error handling)
|
// Calculate coverage (with better error handling)
|
||||||
const handleCalculate = useCallback(async () => {
|
const handleCalculate = useCallback(async () => {
|
||||||
const currentSites = useSitesStore.getState().sites;
|
const currentSites = useSitesStore.getState().sites;
|
||||||
@@ -305,6 +378,8 @@ export default function App() {
|
|||||||
onDeleteRequest: useCallback((id: string, name: string) => {
|
onDeleteRequest: useCallback((id: string, name: string) => {
|
||||||
setKbDeleteTarget({ id, name });
|
setKbDeleteTarget({ id, name });
|
||||||
}, []),
|
}, []),
|
||||||
|
onUndo: handleUndo,
|
||||||
|
onRedo: handleRedo,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -319,6 +394,29 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
{/* Undo / Redo buttons */}
|
||||||
|
<div className="hidden sm:flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
className="text-slate-400 hover:text-white disabled:text-slate-600 disabled:cursor-not-allowed p-1 transition-colors"
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
className="text-slate-400 hover:text-white disabled:text-slate-600 disabled:cursor-not-allowed p-1 transition-colors"
|
||||||
|
title="Redo (Ctrl+Shift+Z)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||||
|
<path fillRule="evenodd" d="M12.207 2.232a.75.75 0 0 0 .025 1.06l4.146 3.958H6.375a5.375 5.375 0 0 0 0 10.75H9.25a.75.75 0 0 0 0-1.5H6.375a3.875 3.875 0 0 1 0-7.75h10.003l-4.146 3.957a.75.75 0 0 0 1.036 1.085l5.5-5.25a.75.75 0 0 0 0-1.085l-5.5-5.25a.75.75 0 0 0-1.06.025Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowShortcuts(!showShortcuts)}
|
onClick={() => setShowShortcuts(!showShortcuts)}
|
||||||
className="text-slate-400 hover:text-white text-sm hidden sm:inline"
|
className="text-slate-400 hover:text-white text-sm hidden sm:inline"
|
||||||
@@ -418,6 +516,20 @@ export default function App() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Edit */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">Edit</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li className="flex justify-between gap-4">
|
||||||
|
<span>Undo</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Z</kbd>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between gap-4">
|
||||||
|
<span>Redo</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded text-xs font-mono">Ctrl+Shift+Z</kbd>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{/* General */}
|
{/* General */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">General</h4>
|
<h4 className="text-xs font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1">General</h4>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useProjectsStore } from '@/store/projects.ts';
|
import { useProjectsStore } from '@/store/projects.ts';
|
||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
|
import { useDirtyStore } from '@/hooks/useUnsavedChanges.ts';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
||||||
import { logger } from '@/utils/logger.ts';
|
import { logger } from '@/utils/logger.ts';
|
||||||
|
|
||||||
export default function ProjectPanel() {
|
export default function ProjectPanel() {
|
||||||
@@ -12,11 +14,19 @@ export default function ProjectPanel() {
|
|||||||
const loadProject = useProjectsStore((s) => s.loadProject);
|
const loadProject = useProjectsStore((s) => s.loadProject);
|
||||||
const deleteProject = useProjectsStore((s) => s.deleteProject);
|
const deleteProject = useProjectsStore((s) => s.deleteProject);
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
const isDirty = useDirtyStore((s) => s.isDirty);
|
||||||
|
|
||||||
const [projectName, setProjectName] = useState('');
|
const [projectName, setProjectName] = useState('');
|
||||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Confirm dialog state
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{
|
||||||
|
type: 'load' | 'delete';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects();
|
loadProjects();
|
||||||
}, [loadProjects]);
|
}, [loadProjects]);
|
||||||
@@ -30,6 +40,7 @@ export default function ProjectPanel() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await saveProject(name);
|
await saveProject(name);
|
||||||
|
useDirtyStore.getState().markClean();
|
||||||
addToast(`Project "${name}" saved`, 'success');
|
addToast(`Project "${name}" saved`, 'success');
|
||||||
setProjectName('');
|
setProjectName('');
|
||||||
setShowSaveForm(false);
|
setShowSaveForm(false);
|
||||||
@@ -39,11 +50,20 @@ export default function ProjectPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoad = async (id: string, name: string) => {
|
const handleLoadRequest = (id: string, name: string) => {
|
||||||
|
if (isDirty) {
|
||||||
|
setConfirmAction({ type: 'load', id, name });
|
||||||
|
} else {
|
||||||
|
executeLoad(id, name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeLoad = async (id: string, name: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const project = await loadProject(id);
|
const project = await loadProject(id);
|
||||||
if (project) {
|
if (project) {
|
||||||
|
useDirtyStore.getState().markClean();
|
||||||
addToast(`Loaded project "${name}" (${project.sites.length} sites)`, 'success');
|
addToast(`Loaded project "${name}" (${project.sites.length} sites)`, 'success');
|
||||||
} else {
|
} else {
|
||||||
addToast('Project not found', 'error');
|
addToast('Project not found', 'error');
|
||||||
@@ -56,7 +76,11 @@ export default function ProjectPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDeleteRequest = (id: string, name: string) => {
|
||||||
|
setConfirmAction({ type: 'delete', id, name });
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeDelete = async (id: string, name: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteProject(id);
|
await deleteProject(id);
|
||||||
addToast(`Project "${name}" deleted`, 'info');
|
addToast(`Project "${name}" deleted`, 'info');
|
||||||
@@ -66,6 +90,18 @@ export default function ProjectPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!confirmAction) return;
|
||||||
|
const { type, id, name } = confirmAction;
|
||||||
|
setConfirmAction(null);
|
||||||
|
|
||||||
|
if (type === 'load') {
|
||||||
|
await executeLoad(id, name);
|
||||||
|
} else {
|
||||||
|
await executeDelete(id, name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -142,7 +178,7 @@ export default function ProjectPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 flex-shrink-0">
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLoad(project.id, project.name)}
|
onClick={() => handleLoadRequest(project.id, project.name)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded
|
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded
|
||||||
min-w-[40px] min-h-[32px] flex items-center justify-center disabled:opacity-50"
|
min-w-[40px] min-h-[32px] flex items-center justify-center disabled:opacity-50"
|
||||||
@@ -150,7 +186,7 @@ export default function ProjectPanel() {
|
|||||||
Load
|
Load
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(project.id, project.name)}
|
onClick={() => handleDeleteRequest(project.id, project.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
|
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"
|
min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||||
>
|
>
|
||||||
@@ -161,6 +197,27 @@ export default function ProjectPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation dialog */}
|
||||||
|
{confirmAction && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={
|
||||||
|
confirmAction.type === 'delete'
|
||||||
|
? 'Delete Project?'
|
||||||
|
: 'Unsaved Changes'
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
confirmAction.type === 'delete'
|
||||||
|
? `Delete project "${confirmAction.name}"? This cannot be undone.`
|
||||||
|
: `You have unsaved changes. Load project "${confirmAction.name}" anyway?`
|
||||||
|
}
|
||||||
|
confirmLabel={confirmAction.type === 'delete' ? 'Delete' : 'Load'}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ interface ConfirmDialogProps {
|
|||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
/** @deprecated Use variant instead */
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
variant?: 'danger' | 'warning' | 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VARIANT_STYLES = {
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
|
||||||
|
warning: 'bg-amber-500 hover:bg-amber-600 focus:ring-amber-400',
|
||||||
|
info: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export default function ConfirmDialog({
|
export default function ConfirmDialog({
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
@@ -18,11 +26,15 @@ export default function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
danger = false,
|
danger = false,
|
||||||
|
variant,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
// Resolve variant: explicit variant prop takes priority, then legacy danger boolean
|
||||||
|
const resolvedVariant = variant ?? (danger ? 'danger' : 'info');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto-focus the cancel button (safer default)
|
// Auto-focus the confirm button
|
||||||
confirmRef.current?.focus();
|
confirmRef.current?.focus();
|
||||||
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -67,10 +79,7 @@ export default function ConfirmDialog({
|
|||||||
ref={confirmRef}
|
ref={confirmRef}
|
||||||
onClick={onConfirm}
|
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
|
className={`px-4 py-2 text-sm rounded-md text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||||
${danger
|
${VARIANT_STYLES[resolvedVariant]}`}
|
||||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface ShortcutHandlers {
|
|||||||
onCloseForm: () => void;
|
onCloseForm: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
onDeleteRequest?: (siteId: string, siteName: string) => void;
|
onDeleteRequest?: (siteId: string, siteName: string) => void;
|
||||||
|
onUndo?: () => void;
|
||||||
|
onRedo?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInputActive(): boolean {
|
function isInputActive(): boolean {
|
||||||
@@ -23,6 +25,8 @@ export function useKeyboardShortcuts({
|
|||||||
onCloseForm,
|
onCloseForm,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onDeleteRequest,
|
onDeleteRequest,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
}: ShortcutHandlers) {
|
}: ShortcutHandlers) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -36,6 +40,20 @@ export function useKeyboardShortcuts({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Undo: Ctrl+Z (or Cmd+Z on Mac)
|
||||||
|
if (modKey && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onUndo?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
|
||||||
|
if (modKey && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onRedo?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Escape always works
|
// Escape always works
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
useSitesStore.getState().selectSite(null);
|
useSitesStore.getState().selectSite(null);
|
||||||
@@ -118,5 +136,5 @@ export function useKeyboardShortcuts({
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onCalculate, onCloseForm, onShowShortcuts, onDeleteRequest]);
|
}, [onCalculate, onCloseForm, onShowShortcuts, onDeleteRequest, onUndo, onRedo]);
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/hooks/useUnsavedChanges.ts
Normal file
76
frontend/src/hooks/useUnsavedChanges.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
|
import { useCoverageStore } from '@/store/coverage.ts';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface DirtyState {
|
||||||
|
isDirty: boolean;
|
||||||
|
lastSavedSnapshot: string | null;
|
||||||
|
markDirty: () => void;
|
||||||
|
markClean: () => void;
|
||||||
|
setSnapshot: (snapshot: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDirtyStore = create<DirtyState>((set) => ({
|
||||||
|
isDirty: false,
|
||||||
|
lastSavedSnapshot: null,
|
||||||
|
markDirty: () => set({ isDirty: true }),
|
||||||
|
markClean: () => set({ isDirty: false }),
|
||||||
|
setSnapshot: (snapshot: string) => set({ lastSavedSnapshot: snapshot, isDirty: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getCurrentSnapshot(): string {
|
||||||
|
const sites = useSitesStore.getState().sites;
|
||||||
|
const settings = useCoverageStore.getState().settings;
|
||||||
|
return JSON.stringify({ sites: sites.map((s) => s.id), settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that tracks unsaved changes and warns on page unload.
|
||||||
|
* Call markClean() after a successful save.
|
||||||
|
*/
|
||||||
|
export function useUnsavedChanges() {
|
||||||
|
const isDirty = useDirtyStore((s) => s.isDirty);
|
||||||
|
const prevSnapshot = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Take initial snapshot
|
||||||
|
useEffect(() => {
|
||||||
|
prevSnapshot.current = getCurrentSnapshot();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Watch sites array for changes and mark dirty
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = useSitesStore.subscribe((state) => {
|
||||||
|
const snap = JSON.stringify(state.sites.map((s) => s.id));
|
||||||
|
if (prevSnapshot.current !== null && snap !== prevSnapshot.current) {
|
||||||
|
useDirtyStore.getState().markDirty();
|
||||||
|
}
|
||||||
|
prevSnapshot.current = snap;
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// beforeunload warning
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (useDirtyStore.getState().isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markClean = useCallback(() => {
|
||||||
|
prevSnapshot.current = getCurrentSnapshot();
|
||||||
|
useDirtyStore.getState().markClean();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markDirty = useCallback(() => {
|
||||||
|
useDirtyStore.getState().markDirty();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isDirty, markClean, markDirty };
|
||||||
|
}
|
||||||
108
frontend/src/store/history.ts
Normal file
108
frontend/src/store/history.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Site, CoverageSettings } from '@/types/index.ts';
|
||||||
|
|
||||||
|
export interface ProjectSnapshot {
|
||||||
|
sites: Site[];
|
||||||
|
settings: CoverageSettings;
|
||||||
|
timestamp: number;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryState {
|
||||||
|
past: ProjectSnapshot[];
|
||||||
|
future: ProjectSnapshot[];
|
||||||
|
maxHistory: number;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
push: (snapshot: ProjectSnapshot) => void;
|
||||||
|
undo: () => ProjectSnapshot | null;
|
||||||
|
redo: () => ProjectSnapshot | null;
|
||||||
|
clear: () => void;
|
||||||
|
|
||||||
|
// Computed (not real zustand selectors — use getState())
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHistoryStore = create<HistoryState>((set, get) => ({
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
maxHistory: 50,
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
|
||||||
|
push: (snapshot: ProjectSnapshot) => {
|
||||||
|
set((state) => {
|
||||||
|
const past = [...state.past, snapshot];
|
||||||
|
// Trim oldest if exceeding max
|
||||||
|
if (past.length > state.maxHistory) {
|
||||||
|
past.shift();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
past,
|
||||||
|
future: [], // new action clears redo stack
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: () => {
|
||||||
|
const { past } = get();
|
||||||
|
if (past.length === 0) return null;
|
||||||
|
|
||||||
|
const snapshot = past[past.length - 1];
|
||||||
|
set((state) => {
|
||||||
|
const newPast = state.past.slice(0, -1);
|
||||||
|
return {
|
||||||
|
past: newPast,
|
||||||
|
// We don't push to future here — the caller does that with the current state
|
||||||
|
canUndo: newPast.length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
const { future } = get();
|
||||||
|
if (future.length === 0) return null;
|
||||||
|
|
||||||
|
const snapshot = future[future.length - 1];
|
||||||
|
set((state) => {
|
||||||
|
const newFuture = state.future.slice(0, -1);
|
||||||
|
return {
|
||||||
|
future: newFuture,
|
||||||
|
canRedo: newFuture.length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => {
|
||||||
|
set({ past: [], future: [], canUndo: false, canRedo: false });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the current state to the redo stack.
|
||||||
|
* Called by the undo handler before restoring a previous snapshot.
|
||||||
|
*/
|
||||||
|
export function pushToFuture(snapshot: ProjectSnapshot) {
|
||||||
|
useHistoryStore.setState((state) => ({
|
||||||
|
future: [...state.future, snapshot],
|
||||||
|
canRedo: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the current state to the undo stack before restoring from redo.
|
||||||
|
*/
|
||||||
|
export function pushToPast(snapshot: ProjectSnapshot) {
|
||||||
|
useHistoryStore.setState((state) => {
|
||||||
|
const past = [...state.past, snapshot];
|
||||||
|
if (past.length > state.maxHistory) {
|
||||||
|
past.shift();
|
||||||
|
}
|
||||||
|
return { past, canUndo: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,21 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import type { Site, SiteFormData } from '@/types/index.ts';
|
import type { Site, SiteFormData } from '@/types/index.ts';
|
||||||
import { db } from '@/db/schema.ts';
|
import { db } from '@/db/schema.ts';
|
||||||
import { useCoverageStore } from '@/store/coverage.ts';
|
import { useCoverageStore } from '@/store/coverage.ts';
|
||||||
|
import { useHistoryStore } from '@/store/history.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a snapshot of current state and push it to the undo stack.
|
||||||
|
* Call this BEFORE mutating state.
|
||||||
|
*/
|
||||||
|
function pushSnapshot(action: string, sites: Site[]) {
|
||||||
|
const settings = useCoverageStore.getState().settings;
|
||||||
|
useHistoryStore.getState().push({
|
||||||
|
sites: structuredClone(sites),
|
||||||
|
settings: { ...settings },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const SITE_COLORS = [
|
const SITE_COLORS = [
|
||||||
'#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6',
|
'#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6',
|
||||||
@@ -65,6 +80,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addSite: async (data: SiteFormData) => {
|
addSite: async (data: SiteFormData) => {
|
||||||
|
pushSnapshot('add site', get().sites);
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const colorIndex = get().sites.length % SITE_COLORS.length;
|
const colorIndex = get().sites.length % SITE_COLORS.length;
|
||||||
@@ -90,6 +106,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
const sites = get().sites;
|
const sites = get().sites;
|
||||||
const existing = sites.find((s) => s.id === id);
|
const existing = sites.find((s) => s.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
pushSnapshot('update site', sites);
|
||||||
|
|
||||||
const updated: Site = {
|
const updated: Site = {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -108,6 +125,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteSite: async (id: string) => {
|
deleteSite: async (id: string) => {
|
||||||
|
pushSnapshot('delete site', get().sites);
|
||||||
await db.sites.delete(id);
|
await db.sites.delete(id);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
sites: state.sites.filter((s) => s.id !== id),
|
sites: state.sites.filter((s) => s.id !== id),
|
||||||
@@ -127,6 +145,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
cloneSiteAsSectors: async (siteId: string, sectorCount: 2 | 3) => {
|
cloneSiteAsSectors: async (siteId: string, sectorCount: 2 | 3) => {
|
||||||
const source = get().sites.find((s) => s.id === siteId);
|
const source = get().sites.find((s) => s.id === siteId);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
pushSnapshot('clone as sectors', get().sites);
|
||||||
|
|
||||||
const spacing = 360 / sectorCount;
|
const spacing = 360 / sectorCount;
|
||||||
const addSite = get().addSite;
|
const addSite = get().addSite;
|
||||||
@@ -159,6 +178,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
cloneSector: async (siteId: string) => {
|
cloneSector: async (siteId: string) => {
|
||||||
const source = get().sites.find((s) => s.id === siteId);
|
const source = get().sites.find((s) => s.id === siteId);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
pushSnapshot('clone sector', get().sites);
|
||||||
|
|
||||||
// Count existing sectors at this location to determine naming
|
// Count existing sectors at this location to determine naming
|
||||||
const colocated = get().sites.filter(
|
const colocated = get().sites.filter(
|
||||||
@@ -196,6 +216,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
// Import sites from parsed data
|
// Import sites from parsed data
|
||||||
importSites: async (sitesData: SiteFormData[]) => {
|
importSites: async (sitesData: SiteFormData[]) => {
|
||||||
|
pushSnapshot('import sites', get().sites);
|
||||||
const addSite = get().addSite;
|
const addSite = get().addSite;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const data of sitesData) {
|
for (const data of sitesData) {
|
||||||
@@ -229,6 +250,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchUpdateHeight: async (adjustment: number) => {
|
batchUpdateHeight: async (adjustment: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch update height', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -257,6 +279,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchSetHeight: async (height: number) => {
|
batchSetHeight: async (height: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch set height', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const clampedHeight = Math.max(1, Math.min(100, height));
|
const clampedHeight = Math.max(1, Math.min(100, height));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -286,6 +309,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchAdjustAzimuth: async (delta: number) => {
|
batchAdjustAzimuth: async (delta: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch adjust azimuth', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -315,6 +339,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchSetAzimuth: async (azimuth: number) => {
|
batchSetAzimuth: async (azimuth: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch set azimuth', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const clamped = ((azimuth % 360) + 360) % 360;
|
const clamped = ((azimuth % 360) + 360) % 360;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -344,6 +369,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchAdjustPower: async (delta: number) => {
|
batchAdjustPower: async (delta: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch adjust power', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -372,6 +398,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchSetPower: async (power: number) => {
|
batchSetPower: async (power: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch set power', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const clamped = Math.max(10, Math.min(50, power));
|
const clamped = Math.max(10, Math.min(50, power));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -401,6 +428,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchAdjustTilt: async (delta: number) => {
|
batchAdjustTilt: async (delta: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch adjust tilt', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -430,6 +458,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchSetTilt: async (tilt: number) => {
|
batchSetTilt: async (tilt: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch set tilt', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const clamped = Math.max(-90, Math.min(90, tilt));
|
const clamped = Math.max(-90, Math.min(90, tilt));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -459,6 +488,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
|||||||
|
|
||||||
batchSetFrequency: async (frequency: number) => {
|
batchSetFrequency: async (frequency: number) => {
|
||||||
const { sites, selectedSiteIds } = get();
|
const { sites, selectedSiteIds } = get();
|
||||||
|
pushSnapshot('batch set frequency', sites);
|
||||||
const selectedSet = new Set(selectedSiteIds);
|
const selectedSet = new Set(selectedSiteIds);
|
||||||
const clamped = Math.max(100, Math.min(6000, frequency));
|
const clamped = Math.max(100, Math.min(6000, frequency));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
Reference in New Issue
Block a user