@mytec: iter1.1.1 ready for testing
This commit is contained in:
@@ -3,10 +3,13 @@ import type { Site } from '@/types/index.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
import { useSettingsStore } from '@/store/settings.ts';
|
||||
import { useHistoryStore, pushToFuture, pushToPast } from '@/store/history.ts';
|
||||
import { RFCalculator } from '@/rf/calculator.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
||||
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges.ts';
|
||||
import { logger } from '@/utils/logger.ts';
|
||||
import { db } from '@/db/schema.ts';
|
||||
import MapView from '@/components/map/Map.tsx';
|
||||
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
||||
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
|
||||
@@ -26,6 +29,26 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
||||
|
||||
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() {
|
||||
const loadSites = useSitesStore((s) => s.loadSites);
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
@@ -52,6 +75,13 @@ export default function App() {
|
||||
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||
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<{
|
||||
isOpen: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
@@ -202,6 +232,49 @@ export default function App() {
|
||||
}
|
||||
}, [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)
|
||||
const handleCalculate = useCallback(async () => {
|
||||
const currentSites = useSitesStore.getState().sites;
|
||||
@@ -305,6 +378,8 @@ export default function App() {
|
||||
onDeleteRequest: useCallback((id: string, name: string) => {
|
||||
setKbDeleteTarget({ id, name });
|
||||
}, []),
|
||||
onUndo: handleUndo,
|
||||
onRedo: handleRedo,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -319,6 +394,29 @@ export default function App() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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
|
||||
onClick={() => setShowShortcuts(!showShortcuts)}
|
||||
className="text-slate-400 hover:text-white text-sm hidden sm:inline"
|
||||
@@ -418,6 +516,20 @@ export default function App() {
|
||||
</li>
|
||||
</ul>
|
||||
</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 */}
|
||||
<div>
|
||||
<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 { useProjectsStore } from '@/store/projects.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import { useDirtyStore } from '@/hooks/useUnsavedChanges.ts';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
|
||||
import { logger } from '@/utils/logger.ts';
|
||||
|
||||
export default function ProjectPanel() {
|
||||
@@ -12,11 +14,19 @@ export default function ProjectPanel() {
|
||||
const loadProject = useProjectsStore((s) => s.loadProject);
|
||||
const deleteProject = useProjectsStore((s) => s.deleteProject);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
const isDirty = useDirtyStore((s) => s.isDirty);
|
||||
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Confirm dialog state
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
type: 'load' | 'delete';
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, [loadProjects]);
|
||||
@@ -30,6 +40,7 @@ export default function ProjectPanel() {
|
||||
|
||||
try {
|
||||
await saveProject(name);
|
||||
useDirtyStore.getState().markClean();
|
||||
addToast(`Project "${name}" saved`, 'success');
|
||||
setProjectName('');
|
||||
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);
|
||||
try {
|
||||
const project = await loadProject(id);
|
||||
if (project) {
|
||||
useDirtyStore.getState().markClean();
|
||||
addToast(`Loaded project "${name}" (${project.sites.length} sites)`, 'success');
|
||||
} else {
|
||||
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 {
|
||||
await deleteProject(id);
|
||||
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) => {
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
@@ -142,7 +178,7 @@ export default function ProjectPanel() {
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleLoad(project.id, project.name)}
|
||||
onClick={() => handleLoadRequest(project.id, project.name)}
|
||||
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
|
||||
min-w-[40px] min-h-[32px] flex items-center justify-center disabled:opacity-50"
|
||||
@@ -150,7 +186,7 @@ export default function ProjectPanel() {
|
||||
Load
|
||||
</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
|
||||
min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
@@ -161,6 +197,27 @@ export default function ProjectPanel() {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,17 @@ interface ConfirmDialogProps {
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
/** @deprecated Use variant instead */
|
||||
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({
|
||||
title,
|
||||
message,
|
||||
@@ -18,11 +26,15 @@ export default function ConfirmDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
danger = false,
|
||||
variant,
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Resolve variant: explicit variant prop takes priority, then legacy danger boolean
|
||||
const resolvedVariant = variant ?? (danger ? 'danger' : 'info');
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus the cancel button (safer default)
|
||||
// Auto-focus the confirm button
|
||||
confirmRef.current?.focus();
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -67,10 +79,7 @@ export default function ConfirmDialog({
|
||||
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'
|
||||
}`}
|
||||
${VARIANT_STYLES[resolvedVariant]}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,8 @@ interface ShortcutHandlers {
|
||||
onCloseForm: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onDeleteRequest?: (siteId: string, siteName: string) => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
}
|
||||
|
||||
function isInputActive(): boolean {
|
||||
@@ -23,6 +25,8 @@ export function useKeyboardShortcuts({
|
||||
onCloseForm,
|
||||
onShowShortcuts,
|
||||
onDeleteRequest,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: ShortcutHandlers) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -36,6 +40,20 @@ export function useKeyboardShortcuts({
|
||||
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
|
||||
if (e.key === 'Escape') {
|
||||
useSitesStore.getState().selectSite(null);
|
||||
@@ -118,5 +136,5 @@ export function useKeyboardShortcuts({
|
||||
|
||||
window.addEventListener('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 { db } from '@/db/schema.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 = [
|
||||
'#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6',
|
||||
@@ -65,6 +80,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
},
|
||||
|
||||
addSite: async (data: SiteFormData) => {
|
||||
pushSnapshot('add site', get().sites);
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
const colorIndex = get().sites.length % SITE_COLORS.length;
|
||||
@@ -90,6 +106,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
const sites = get().sites;
|
||||
const existing = sites.find((s) => s.id === id);
|
||||
if (!existing) return;
|
||||
pushSnapshot('update site', sites);
|
||||
|
||||
const updated: Site = {
|
||||
...existing,
|
||||
@@ -108,6 +125,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
},
|
||||
|
||||
deleteSite: async (id: string) => {
|
||||
pushSnapshot('delete site', get().sites);
|
||||
await db.sites.delete(id);
|
||||
set((state) => ({
|
||||
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) => {
|
||||
const source = get().sites.find((s) => s.id === siteId);
|
||||
if (!source) return;
|
||||
pushSnapshot('clone as sectors', get().sites);
|
||||
|
||||
const spacing = 360 / sectorCount;
|
||||
const addSite = get().addSite;
|
||||
@@ -159,6 +178,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
cloneSector: async (siteId: string) => {
|
||||
const source = get().sites.find((s) => s.id === siteId);
|
||||
if (!source) return;
|
||||
pushSnapshot('clone sector', get().sites);
|
||||
|
||||
// Count existing sectors at this location to determine naming
|
||||
const colocated = get().sites.filter(
|
||||
@@ -196,6 +216,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
// Import sites from parsed data
|
||||
importSites: async (sitesData: SiteFormData[]) => {
|
||||
pushSnapshot('import sites', get().sites);
|
||||
const addSite = get().addSite;
|
||||
let count = 0;
|
||||
for (const data of sitesData) {
|
||||
@@ -229,6 +250,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchUpdateHeight: async (adjustment: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch update height', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const now = new Date();
|
||||
|
||||
@@ -257,6 +279,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchSetHeight: async (height: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch set height', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clampedHeight = Math.max(1, Math.min(100, height));
|
||||
const now = new Date();
|
||||
@@ -286,6 +309,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchAdjustAzimuth: async (delta: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch adjust azimuth', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const now = new Date();
|
||||
|
||||
@@ -315,6 +339,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchSetAzimuth: async (azimuth: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch set azimuth', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clamped = ((azimuth % 360) + 360) % 360;
|
||||
const now = new Date();
|
||||
@@ -344,6 +369,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchAdjustPower: async (delta: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch adjust power', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const now = new Date();
|
||||
|
||||
@@ -372,6 +398,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchSetPower: async (power: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch set power', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clamped = Math.max(10, Math.min(50, power));
|
||||
const now = new Date();
|
||||
@@ -401,6 +428,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchAdjustTilt: async (delta: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch adjust tilt', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const now = new Date();
|
||||
|
||||
@@ -430,6 +458,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchSetTilt: async (tilt: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch set tilt', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clamped = Math.max(-90, Math.min(90, tilt));
|
||||
const now = new Date();
|
||||
@@ -459,6 +488,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
|
||||
batchSetFrequency: async (frequency: number) => {
|
||||
const { sites, selectedSiteIds } = get();
|
||||
pushSnapshot('batch set frequency', sites);
|
||||
const selectedSet = new Set(selectedSiteIds);
|
||||
const clamped = Math.max(100, Math.min(6000, frequency));
|
||||
const now = new Date();
|
||||
|
||||
Reference in New Issue
Block a user