Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
3 changed files with 208 additions and 108 deletions
Showing only changes of commit 604025588e - Show all commits

View File

@ -0,0 +1,28 @@
import LocationMapTopbar from "./LocationMapTopbar";
export function LocationMapMessageState({ message }) {
return (
<div className="location-map-page">
<p className="location-map-loading">{message}</p>
</div>
);
}
export function LocationMapLoadErrorState({ location, loadError, onBack, onRetry }) {
return (
<div className="location-map-page">
<LocationMapTopbar location={location} status="Load Error" onBack={onBack} />
<main className="location-map-workspace">
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
<h2>Map Unavailable</h2>
<p>{loadError}</p>
<div className="location-map-setup-actions">
<button type="button" className="btn-primary" onClick={onRetry}>
Retry
</button>
</div>
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,138 @@
import { useCallback, useEffect } from "react";
import {
DEFAULT_MAP_SIZE,
clampMapZoom,
getObjectKey,
} from "../lib/locationMapUtils";
export default function useLocationMapViewControls({
editorTool,
filters,
hasUnsavedChanges,
mapSize,
mapState,
mode,
objects,
previewDraft,
selectedObjectKey,
setEditorTool,
setFuture,
setHasUnsavedChanges,
setHistory,
setMode,
setObjects,
setPreviewDraft,
setSelectedObjectKey,
setZoom,
svgRef,
syncMapDraftFromState,
}) {
useEffect(() => {
if (mode !== "edit") {
setEditorTool("pan");
return;
}
if (editorTool === "pan") {
setSelectedObjectKey(null);
}
}, [editorTool, mode, setEditorTool, setSelectedObjectKey]);
useEffect(() => {
if (!selectedObjectKey) return;
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
setSelectedObjectKey(null);
}
}, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]);
const handleUndo = useCallback(() => {
setHistory((previous) => {
if (previous.length === 0) return previous;
const priorObjects = previous[previous.length - 1];
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
setObjects(priorObjects);
setHasUnsavedChanges(true);
return previous.slice(0, -1);
});
}, [objects, setFuture, setHasUnsavedChanges, setHistory, setObjects]);
const handleRedo = useCallback(() => {
setFuture((previous) => {
if (previous.length === 0) return previous;
const nextObjects = previous[0];
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
setObjects(nextObjects);
setHasUnsavedChanges(true);
return previous.slice(1);
});
}, [objects, setFuture, setHasUnsavedChanges, setHistory, setObjects]);
const handlePreviewDraft = useCallback(() => {
setMode("view");
setEditorTool("pan");
setPreviewDraft(true);
setSelectedObjectKey(null);
}, [setEditorTool, setMode, setPreviewDraft, setSelectedObjectKey]);
const handleViewMode = useCallback(() => {
setMode("view");
setEditorTool("pan");
if (hasUnsavedChanges) {
setPreviewDraft(true);
setSelectedObjectKey(null);
return;
}
setPreviewDraft(false);
if (mapState) {
syncMapDraftFromState(mapState, "view", false);
}
}, [
hasUnsavedChanges,
mapState,
setEditorTool,
setMode,
setPreviewDraft,
setSelectedObjectKey,
syncMapDraftFromState,
]);
const handleEditMode = useCallback(() => {
setMode("edit");
setEditorTool("edit");
setPreviewDraft(true);
if (!previewDraft && mapState && !hasUnsavedChanges) {
syncMapDraftFromState(mapState, "edit", true);
}
}, [
hasUnsavedChanges,
mapState,
previewDraft,
setEditorTool,
setMode,
setPreviewDraft,
syncMapDraftFromState,
]);
const handleFitMap = useCallback(() => {
const scrollElement = svgRef.current?.closest(".location-map-scroll");
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24);
const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24);
const nextZoom = clampMapZoom(
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
);
setZoom(Number(nextZoom.toFixed(2)));
}, [mapSize.height, mapSize.width, setZoom, svgRef]);
return {
handleEditMode,
handleFitMap,
handlePreviewDraft,
handleRedo,
handleUndo,
handleViewMode,
};
}

View File

@ -6,6 +6,10 @@ import { StoreContext } from "../context/StoreContext";
import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet";
import LocationMapCanvas from "../components/maps/LocationMapCanvas";
import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
import {
LocationMapLoadErrorState,
LocationMapMessageState,
} from "../components/maps/LocationMapStateViews";
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
@ -13,10 +17,10 @@ import useActionToast from "../hooks/useActionToast";
import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions";
import useLocationMapViewControls from "../hooks/useLocationMapViewControls";
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
import {
DEFAULT_MAP_SIZE,
clampMapZoom,
getMapObjectDisplayLabel,
getMapStatus,
getObjectKey,
@ -145,26 +149,6 @@ export default function LocationMapManager() {
}
}, [activeStore?.id, locationId, setActiveStore, stores]);
useEffect(() => {
if (mode !== "edit") {
setEditorTool("pan");
return;
}
if (editorTool === "pan") {
setSelectedObjectKey(null);
}
}, [editorTool, mode]);
useEffect(() => {
if (!selectedObjectKey) return;
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
setSelectedObjectKey(null);
}
}, [filters.showZones, objects, selectedObjectKey]);
const {
handleCreateBlank,
handleCreateFromZones,
@ -215,69 +199,35 @@ export default function LocationMapManager() {
toast,
});
const handleUndo = () => {
setHistory((previous) => {
if (previous.length === 0) return previous;
const priorObjects = previous[previous.length - 1];
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
setObjects(priorObjects);
setHasUnsavedChanges(true);
return previous.slice(0, -1);
});
};
const handleRedo = () => {
setFuture((previous) => {
if (previous.length === 0) return previous;
const nextObjects = previous[0];
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
setObjects(nextObjects);
setHasUnsavedChanges(true);
return previous.slice(1);
});
};
const handlePreviewDraft = () => {
setMode("view");
setEditorTool("pan");
setPreviewDraft(true);
setSelectedObjectKey(null);
};
const handleViewMode = () => {
setMode("view");
setEditorTool("pan");
if (hasUnsavedChanges) {
setPreviewDraft(true);
setSelectedObjectKey(null);
return;
}
setPreviewDraft(false);
if (mapState) {
syncMapDraftFromState(mapState, "view", false);
}
};
const handleEditMode = () => {
setMode("edit");
setEditorTool("edit");
setPreviewDraft(true);
if (!previewDraft && mapState && !hasUnsavedChanges) {
syncMapDraftFromState(mapState, "edit", true);
}
};
const handleFitMap = () => {
const scrollElement = svgRef.current?.closest(".location-map-scroll");
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24);
const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24);
const nextZoom = clampMapZoom(
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
);
setZoom(Number(nextZoom.toFixed(2)));
};
const {
handleEditMode,
handleFitMap,
handlePreviewDraft,
handleRedo,
handleUndo,
handleViewMode,
} = useLocationMapViewControls({
editorTool,
filters,
hasUnsavedChanges,
mapSize,
mapState,
mode,
objects,
previewDraft,
selectedObjectKey,
setEditorTool,
setFuture,
setHasUnsavedChanges,
setHistory,
setMode,
setObjects,
setPreviewDraft,
setSelectedObjectKey,
setZoom,
svgRef,
syncMapDraftFromState,
});
const performBackNavigation = useCallback(() => {
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
@ -299,37 +249,21 @@ export default function LocationMapManager() {
});
if (!hasLoaded || householdLoading || loading) {
return (
<div className="location-map-page">
<p className="location-map-loading">Loading map...</p>
</div>
);
return <LocationMapMessageState message="Loading map..." />;
}
if (!activeHousehold) {
return (
<div className="location-map-page">
<p className="location-map-loading">Select a household to manage maps.</p>
</div>
);
return <LocationMapMessageState message="Select a household to manage maps." />;
}
if (loadError) {
return (
<div className="location-map-page">
<LocationMapTopbar location={location} status="Load Error" onBack={requestBackNavigation} />
<main className="location-map-workspace">
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
<h2>Map Unavailable</h2>
<p>{loadError}</p>
<div className="location-map-setup-actions">
<button type="button" className="btn-primary" onClick={loadMap}>
Retry
</button>
</div>
</section>
</main>
</div>
<LocationMapLoadErrorState
location={location}
loadError={loadError}
onBack={requestBackNavigation}
onRetry={loadMap}
/>
);
}