feat: polish location map editor

This commit is contained in:
Nico 2026-06-03 01:23:12 -07:00
parent 78893545b2
commit c100174ea8
6 changed files with 476 additions and 88 deletions

View File

@ -17,9 +17,12 @@ const DISPLAY_CONTROLS = [
export default function LocationMapBottomSheet({ export default function LocationMapBottomSheet({
mode, mode,
editorTool,
canManage, canManage,
filters, filters,
setFilters, setFilters,
layersOpen,
setLayersOpen,
selectedObject, selectedObject,
selectedZoneItems, selectedZoneItems,
visibleUnmappedItems, visibleUnmappedItems,
@ -32,43 +35,68 @@ export default function LocationMapBottomSheet({
onRedo, onRedo,
onSaveDraft, onSaveDraft,
onPublish, onPublish,
onPanMode,
onEditObjects,
onEditMode, onEditMode,
onObjectField, onObjectField,
onZoneLinkChange, onZoneLinkChange,
onDuplicateObject, onDuplicateObject,
onDeleteObject, onDeleteObject,
}) { }) {
const selectedTitle = selectedObject?.label || selectedObject?.zone_name || "Map Area";
const sheetTitle = mode === "edit"
? selectedObject && editorTool === "edit"
? "Object Settings"
: editorTool === "edit"
? "Edit Objects"
: "Pan Mode"
: selectedObject
? selectedTitle
: "Map Details";
return ( return (
<aside className="location-map-bottom-sheet"> <aside className="location-map-bottom-sheet">
<div className="location-map-sheet-header"> <div className="location-map-sheet-header">
<strong>{mode === "edit" ? "Editor" : "Map Details"}</strong> <div>
{filters.showUnmapped && visibleUnmappedItems.length > 0 ? ( <strong>{sheetTitle}</strong>
<span>{visibleUnmappedItems.length} unmapped</span> {mode === "edit" && canManage ? (
) : null} <span>{editorTool === "edit" ? "Move, resize, and configure selected areas." : "Drag and zoom without changing objects."}</span>
) : null}
</div>
<button
type="button"
className={`location-map-layer-toggle ${layersOpen ? "active" : ""}`}
onClick={() => setLayersOpen((current) => !current)}
aria-expanded={layersOpen}
>
Layers
</button>
</div> </div>
<div className="location-map-display-controls"> {layersOpen ? (
{DISPLAY_CONTROLS.map(([key, label]) => ( <div className="location-map-display-controls">
<label key={key}> {DISPLAY_CONTROLS.map(([key, label]) => (
<input <label key={key}>
type="checkbox" <input
checked={filters[key]} type="checkbox"
onChange={(event) => checked={filters[key]}
setFilters((current) => ({ ...current, [key]: event.target.checked })) onChange={(event) =>
} setFilters((current) => ({ ...current, [key]: event.target.checked }))
/> }
{label} />
</label> {label}
))} </label>
</div> ))}
</div>
) : null}
{mode === "edit" && canManage ? ( {mode === "edit" && canManage ? (
<div className="location-map-editor-actions"> <div className="location-map-editor-actions location-map-primary-actions">
<button type="button" onClick={onAddObject}>Add Zone Rectangle</button> <button type="button" onClick={onAddObject}>Add Area</button>
<button type="button" onClick={onUndo} disabled={history.length === 0}>Undo</button>
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
<button type="button" onClick={onSaveDraft} disabled={saving}>Save Draft</button> <button type="button" onClick={onSaveDraft} disabled={saving}>Save Draft</button>
<button type="button" onClick={onPublish} disabled={saving || !mapState?.draft_map}>Publish</button> <button type="button" onClick={onPublish} disabled={saving || !mapState?.draft_map}>Publish</button>
<button type="button" onClick={onUndo} disabled={history.length === 0}>Undo</button>
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
</div> </div>
) : canManage && mapState?.published_map ? ( ) : canManage && mapState?.published_map ? (
<div className="location-map-editor-actions"> <div className="location-map-editor-actions">
@ -76,7 +104,26 @@ export default function LocationMapBottomSheet({
</div> </div>
) : null} ) : null}
{mode === "edit" && selectedObject ? ( {mode === "edit" && canManage ? (
<div className="location-map-mobile-tool-switch" aria-label="Edit tool">
<button
type="button"
className={editorTool === "pan" ? "active" : ""}
onClick={onPanMode}
>
Pan
</button>
<button
type="button"
className={editorTool === "edit" ? "active" : ""}
onClick={onEditObjects}
>
Objects
</button>
</div>
) : null}
{mode === "edit" && editorTool === "edit" && selectedObject ? (
<div className="location-map-object-form"> <div className="location-map-object-form">
<label> <label>
Label Label
@ -144,12 +191,23 @@ export default function LocationMapBottomSheet({
</div> </div>
<div className="location-map-editor-actions"> <div className="location-map-editor-actions">
<button type="button" onClick={onDuplicateObject}>Duplicate</button> <button type="button" onClick={onDuplicateObject}>Duplicate</button>
<button type="button" className="danger" onClick={onDeleteObject}>Delete Rectangle</button> <button type="button" className="danger" onClick={onDeleteObject}>Delete</button>
</div> </div>
</div> </div>
) : mode === "edit" && editorTool === "edit" ? (
<p className="location-map-muted">
Tap an area to edit its name, linked zone, size, and position.
</p>
) : mode === "edit" ? (
<p className="location-map-muted">
Use Pan Mode to move around the map. Switch to Objects when you want to edit areas.
</p>
) : selectedObject ? ( ) : selectedObject ? (
<div className="location-map-zone-items"> <div className="location-map-zone-items">
<h2>{selectedObject.label || selectedObject.zone_name || "Map Area"}</h2> <div className="location-map-zone-items-title">
<h2>{selectedTitle}</h2>
<span>{selectedZoneItems.length}</span>
</div>
{selectedZoneItems.length === 0 ? ( {selectedZoneItems.length === 0 ? (
<p className="location-map-muted">No visible items assigned to this zone.</p> <p className="location-map-muted">No visible items assigned to this zone.</p>
) : ( ) : (
@ -165,7 +223,7 @@ export default function LocationMapBottomSheet({
</div> </div>
) : ( ) : (
<p className="location-map-muted"> <p className="location-map-muted">
{mode === "edit" ? "Tap a rectangle to edit it." : "Tap a zone to view assigned items."} Tap a zone to view assigned items. Item pins stay hidden unless you turn them on in Layers.
</p> </p>
)} )}

View File

@ -6,6 +6,7 @@ import {
export default function LocationMapCanvas({ export default function LocationMapCanvas({
mode, mode,
editorTool,
objects, objects,
filters, filters,
mapSize, mapSize,
@ -20,11 +21,15 @@ export default function LocationMapCanvas({
remember, remember,
updateObjects, updateObjects,
}) { }) {
const canEditObjects = mode === "edit" && editorTool === "edit";
const startDrag = (event, object, type) => { const startDrag = (event, object, type) => {
if (mode !== "edit" || object.locked || !svgRef.current) return; if (!canEditObjects || object.locked || !svgRef.current) return;
event.stopPropagation(); event.stopPropagation();
event.preventDefault();
remember(); remember();
const point = clientPointToMap(event, svgRef.current, mapSize); const point = clientPointToMap(event, svgRef.current, mapSize);
event.currentTarget.setPointerCapture?.(event.pointerId);
setSelectedObjectKey(getObjectKey(object)); setSelectedObjectKey(getObjectKey(object));
setDragState({ setDragState({
type, type,
@ -36,6 +41,7 @@ export default function LocationMapCanvas({
const handlePointerMove = (event) => { const handlePointerMove = (event) => {
if (!dragState || !svgRef.current) return; if (!dragState || !svgRef.current) return;
event.preventDefault();
const point = clientPointToMap(event, svgRef.current, mapSize); const point = clientPointToMap(event, svgRef.current, mapSize);
const deltaX = point.x - dragState.startPoint.x; const deltaX = point.x - dragState.startPoint.x;
const deltaY = point.y - dragState.startPoint.y; const deltaY = point.y - dragState.startPoint.y;
@ -63,12 +69,33 @@ export default function LocationMapCanvas({
setDragState(null); setDragState(null);
}; };
const handleObjectClick = (event, object) => {
event.stopPropagation();
if (mode === "view" || canEditObjects) {
setSelectedObjectKey(getObjectKey(object));
}
};
const clearSelection = () => {
if (!dragState) {
setSelectedObjectKey(null);
}
};
const fittedLabel = (object, fallback) => {
const rawLabel = object.label || object.zone_name || fallback;
const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 14));
if (rawLabel.length <= maxCharacters) return rawLabel;
return `${rawLabel.slice(0, Math.max(5, maxCharacters - 3))}...`;
};
const renderMapObject = (object, index) => { const renderMapObject = (object, index) => {
if (!object.visible || !filters.showZones) return null; if (!object.visible || !filters.showZones) return null;
const key = getObjectKey(object); const key = getObjectKey(object);
const isSelected = key === selectedObjectKey; const isSelected = key === selectedObjectKey;
const zoneItems = itemsForZone(mapItems, object.zone_id, filters, username); const zoneItems = itemsForZone(mapItems, object.zone_id, filters, username);
const count = zoneItems.length; const count = zoneItems.length;
const countLabel = `${count} item${count === 1 ? "" : "s"}`;
const pinDots = filters.showPins const pinDots = filters.showPins
? zoneItems.slice(0, 12).map((item, itemIndex) => { ? zoneItems.slice(0, 12).map((item, itemIndex) => {
const column = itemIndex % 4; const column = itemIndex % 4;
@ -88,7 +115,11 @@ export default function LocationMapCanvas({
return ( return (
<g <g
key={key} key={key}
className={`location-map-object ${isSelected ? "is-selected" : ""}`} className={[
"location-map-object",
isSelected ? "is-selected" : "",
canEditObjects ? "is-editable" : "",
].filter(Boolean).join(" ")}
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`} transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
> >
<rect <rect
@ -100,27 +131,27 @@ export default function LocationMapCanvas({
role="button" role="button"
aria-label={`Map area ${object.label || index + 1}`} aria-label={`Map area ${object.label || index + 1}`}
onPointerDown={(event) => startDrag(event, object, "move")} onPointerDown={(event) => startDrag(event, object, "move")}
onClick={() => setSelectedObjectKey(key)} onClick={(event) => handleObjectClick(event, object)}
/> />
{filters.showLabels ? ( {filters.showLabels ? (
<text x={object.x + 16} y={object.y + 28} className="location-map-label"> <text x={object.x + 16} y={object.y + 28} className="location-map-label">
{object.label || object.zone_name || "Area"} {fittedLabel(object, "Area")}
</text> </text>
) : null} ) : null}
{filters.showCounts ? ( {filters.showCounts ? (
<text x={object.x + 16} y={object.y + 52} className="location-map-count"> <text x={object.x + 16} y={object.y + 52} className="location-map-count">
{count} item{count === 1 ? "" : "s"} {countLabel}
</text> </text>
) : null} ) : null}
{pinDots} {pinDots}
{mode === "edit" && isSelected ? ( {canEditObjects && isSelected ? (
<rect <rect
className="location-map-resize-handle" className="location-map-resize-handle"
x={object.x + object.width - 24} x={object.x + object.width - 42}
y={object.y + object.height - 24} y={object.y + object.height - 42}
width="24" width="38"
height="24" height="38"
rx="6" rx="10"
onPointerDown={(event) => startDrag(event, object, "resize")} onPointerDown={(event) => startDrag(event, object, "resize")}
/> />
) : null} ) : null}
@ -129,11 +160,20 @@ export default function LocationMapCanvas({
}; };
return ( return (
<section className="location-map-canvas-shell"> <section
className={[
"location-map-canvas-shell",
mode === "edit" ? "is-edit-mode" : "is-view-mode",
editorTool === "edit" ? "is-object-tool" : "is-pan-tool",
].filter(Boolean).join(" ")}
>
<div className="location-map-scroll"> <div className="location-map-scroll">
<svg <svg
ref={svgRef} ref={svgRef}
className="location-map-svg" className={[
"location-map-svg",
canEditObjects ? "is-editable" : "",
].filter(Boolean).join(" ")}
style={{ style={{
width: `${mapSize.width * zoom}px`, width: `${mapSize.width * zoom}px`,
height: `${mapSize.height * zoom}px`, height: `${mapSize.height * zoom}px`,
@ -144,6 +184,7 @@ export default function LocationMapCanvas({
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp} onPointerLeave={handlePointerUp}
onClick={clearSelection}
> >
<defs> <defs>
<pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse"> <pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
@ -156,6 +197,11 @@ export default function LocationMapCanvas({
) : null} ) : null}
{objects.map(renderMapObject)} {objects.map(renderMapObject)}
</svg> </svg>
{objects.length === 0 ? (
<div className="location-map-empty-canvas">
No map areas yet
</div>
) : null}
</div> </div>
</section> </section>
); );

View File

@ -1,43 +1,84 @@
export default function LocationMapToolbar({ export default function LocationMapToolbar({
mode, mode,
editorTool,
hasAnyMap, hasAnyMap,
canManage, canManage,
zoom, zoom,
setZoom, setZoom,
onFit,
onView, onView,
onEdit, onEdit,
onPanMode,
onEditObjects,
}) { }) {
return ( return (
<section className="location-map-toolbar" aria-label="Map controls"> <section className="location-map-toolbar" aria-label="Map controls">
<div className="location-map-mode-buttons"> <div className="location-map-mode-group">
<button <div className="location-map-mode-buttons" aria-label="Map mode">
type="button"
className={mode === "view" ? "active" : ""}
disabled={!hasAnyMap}
onClick={onView}
>
View
</button>
{canManage ? (
<button <button
type="button" type="button"
className={mode === "edit" ? "active" : ""} className={mode === "view" ? "active" : ""}
disabled={!hasAnyMap} disabled={!hasAnyMap}
onClick={onEdit} onClick={onView}
aria-pressed={mode === "view"}
> >
Edit View
</button> </button>
{canManage ? (
<button
type="button"
className={mode === "edit" ? "active" : ""}
disabled={!hasAnyMap}
onClick={onEdit}
aria-pressed={mode === "edit"}
>
Edit Draft
</button>
) : null}
</div>
{mode === "edit" && canManage ? (
<div className="location-map-tool-buttons" aria-label="Edit tool">
<button
type="button"
className={editorTool === "pan" ? "active is-pan" : ""}
onClick={onPanMode}
aria-pressed={editorTool === "pan"}
>
Pan Mode
</button>
<button
type="button"
className={editorTool === "edit" ? "active is-edit" : ""}
onClick={onEditObjects}
aria-pressed={editorTool === "edit"}
>
Edit Objects
</button>
</div>
) : null} ) : null}
</div> </div>
<div className="location-map-zoom-controls"> <div className="location-map-zoom-controls" aria-label="Zoom controls">
<button type="button" onClick={() => setZoom((value) => Math.max(0.45, value - 0.15))}> <button
type="button"
onClick={() => setZoom((value) => Math.max(0.45, value - 0.15))}
disabled={!hasAnyMap}
aria-label="Zoom out"
>
- -
</button> </button>
<span>{Math.round(zoom * 100)}%</span> <span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => setZoom((value) => Math.min(1.6, value + 0.15))}> <button
type="button"
onClick={() => setZoom((value) => Math.min(1.6, value + 0.15))}
disabled={!hasAnyMap}
aria-label="Zoom in"
>
+ +
</button> </button>
<button type="button" onClick={onFit} disabled={!hasAnyMap}>
Fit
</button>
</div> </div>
</section> </section>
); );

View File

@ -15,6 +15,7 @@ import LocationMapCanvas from "../components/maps/LocationMapCanvas";
import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel"; import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
import LocationMapToolbar from "../components/maps/LocationMapToolbar"; import LocationMapToolbar from "../components/maps/LocationMapToolbar";
import LocationMapTopbar from "../components/maps/LocationMapTopbar"; import LocationMapTopbar from "../components/maps/LocationMapTopbar";
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
import useActionToast from "../hooks/useActionToast"; import useActionToast from "../hooks/useActionToast";
import getApiErrorMessage from "../lib/getApiErrorMessage"; import getApiErrorMessage from "../lib/getApiErrorMessage";
import { import {
@ -46,6 +47,7 @@ export default function LocationMapManager() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mode, setMode] = useState("setup"); const [mode, setMode] = useState("setup");
const [editorTool, setEditorTool] = useState("pan");
const [previewDraft, setPreviewDraft] = useState(false); const [previewDraft, setPreviewDraft] = useState(false);
const [mapDraft, setMapDraft] = useState({ const [mapDraft, setMapDraft] = useState({
name: "Store Map", name: "Store Map",
@ -55,10 +57,12 @@ export default function LocationMapManager() {
const [objects, setObjects] = useState([]); const [objects, setObjects] = useState([]);
const [selectedObjectKey, setSelectedObjectKey] = useState(null); const [selectedObjectKey, setSelectedObjectKey] = useState(null);
const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS); const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
const [layersOpen, setLayersOpen] = useState(false);
const [zoom, setZoom] = useState(0.75); const [zoom, setZoom] = useState(0.75);
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [future, setFuture] = useState([]); const [future, setFuture] = useState([]);
const [dragState, setDragState] = useState(null); const [dragState, setDragState] = useState(null);
const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
const svgRef = useRef(null); const svgRef = useRef(null);
const toastRef = useRef(toast); const toastRef = useRef(toast);
@ -87,6 +91,7 @@ export default function LocationMapManager() {
if (!nextState.draft_map && !nextState.published_map) { if (!nextState.draft_map && !nextState.published_map) {
setMode("setup"); setMode("setup");
setEditorTool("pan");
setPreviewDraft(false); setPreviewDraft(false);
setObjects([]); setObjects([]);
setMapDraft({ setMapDraft({
@ -99,9 +104,11 @@ export default function LocationMapManager() {
if (nextState.published_map) { if (nextState.published_map) {
setMode("view"); setMode("view");
setEditorTool("pan");
setPreviewDraft(false); setPreviewDraft(false);
} else if (nextState.draft_map) { } else if (nextState.draft_map) {
setMode("setup"); setMode("setup");
setEditorTool("pan");
setPreviewDraft(true); setPreviewDraft(true);
} }
@ -161,6 +168,17 @@ export default function LocationMapManager() {
setFuture([]); setFuture([]);
}, [mapState, mode, previewDraft]); }, [mapState, mode, previewDraft]);
useEffect(() => {
if (mode !== "edit") {
setEditorTool("pan");
return;
}
if (editorTool === "pan") {
setSelectedObjectKey(null);
}
}, [editorTool, mode]);
const remember = (currentObjects = objects) => { const remember = (currentObjects = objects) => {
setHistory((previous) => [...previous.slice(-19), currentObjects.map((object) => ({ ...object }))]); setHistory((previous) => [...previous.slice(-19), currentObjects.map((object) => ({ ...object }))]);
setFuture([]); setFuture([]);
@ -180,6 +198,7 @@ export default function LocationMapManager() {
const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft); const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft);
setMapState(response.data); setMapState(response.data);
setMode("edit"); setMode("edit");
setEditorTool("edit");
setPreviewDraft(true); setPreviewDraft(true);
toast.success("Created map", "Blank draft map created"); toast.success("Created map", "Blank draft map created");
} catch (error) { } catch (error) {
@ -196,6 +215,7 @@ export default function LocationMapManager() {
const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft); const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft);
setMapState(response.data); setMapState(response.data);
setMode("edit"); setMode("edit");
setEditorTool("edit");
setPreviewDraft(true); setPreviewDraft(true);
toast.success("Created starter map", "Zones were added as editable rectangles"); toast.success("Created starter map", "Zones were added as editable rectangles");
} catch (error) { } catch (error) {
@ -231,6 +251,7 @@ export default function LocationMapManager() {
const response = await publishLocationMapDraft(activeHousehold.id, locationId); const response = await publishLocationMapDraft(activeHousehold.id, locationId);
setMapState(response.data); setMapState(response.data);
setMode("view"); setMode("view");
setEditorTool("pan");
setPreviewDraft(false); setPreviewDraft(false);
toast.success("Published map", "Map is now visible to household members"); toast.success("Published map", "Map is now visible to household members");
} catch (error) { } catch (error) {
@ -248,6 +269,7 @@ export default function LocationMapManager() {
const nextObject = createClientObject(nextZone, objects.length); const nextObject = createClientObject(nextZone, objects.length);
setObjects((currentObjects) => [...currentObjects, nextObject]); setObjects((currentObjects) => [...currentObjects, nextObject]);
setSelectedObjectKey(getObjectKey(nextObject)); setSelectedObjectKey(getObjectKey(nextObject));
setEditorTool("edit");
}; };
const handleDuplicateObject = () => { const handleDuplicateObject = () => {
@ -264,15 +286,26 @@ export default function LocationMapManager() {
}; };
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]); setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
setSelectedObjectKey(getObjectKey(nextObject)); setSelectedObjectKey(getObjectKey(nextObject));
setEditorTool("edit");
};
const requestDeleteObject = () => {
if (!selectedObject) return;
setPendingDeleteObject(selectedObject);
}; };
const handleDeleteObject = () => { const handleDeleteObject = () => {
if (!selectedObject) return; if (!pendingDeleteObject) return;
remember(); remember();
setObjects((currentObjects) => setObjects((currentObjects) =>
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(selectedObject)) currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
); );
setSelectedObjectKey(null); setSelectedObjectKey(null);
toast.success(
"Deleted map area",
"Save the draft to keep this change."
);
setPendingDeleteObject(null);
}; };
const handleObjectField = (field, value) => { const handleObjectField = (field, value) => {
@ -323,6 +356,16 @@ export default function LocationMapManager() {
}); });
}; };
const handleFitMap = () => {
const availableWidth = typeof window === "undefined"
? DEFAULT_MAP_SIZE.width
: window.innerWidth >= 840
? window.innerWidth - 420
: window.innerWidth - 24;
const nextZoom = Math.max(0.45, Math.min(1.6, (availableWidth - 24) / mapSize.width));
setZoom(Number(nextZoom.toFixed(2)));
};
if (!hasLoaded || householdLoading || loading) { if (!hasLoaded || householdLoading || loading) {
return ( return (
<div className="location-map-page"> <div className="location-map-page">
@ -349,18 +392,24 @@ export default function LocationMapManager() {
<main className="location-map-workspace"> <main className="location-map-workspace">
<LocationMapToolbar <LocationMapToolbar
mode={mode} mode={mode}
editorTool={editorTool}
hasAnyMap={hasAnyMap} hasAnyMap={hasAnyMap}
canManage={canManage} canManage={canManage}
zoom={zoom} zoom={zoom}
setZoom={setZoom} setZoom={setZoom}
onFit={handleFitMap}
onView={() => { onView={() => {
setMode("view"); setMode("view");
setEditorTool("pan");
setPreviewDraft(false); setPreviewDraft(false);
}} }}
onEdit={() => { onEdit={() => {
setMode("edit"); setMode("edit");
setEditorTool("pan");
setPreviewDraft(true); setPreviewDraft(true);
}} }}
onPanMode={() => setEditorTool("pan")}
onEditObjects={() => setEditorTool("edit")}
/> />
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? ( {!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
@ -381,6 +430,7 @@ export default function LocationMapManager() {
<> <>
<LocationMapCanvas <LocationMapCanvas
mode={mode} mode={mode}
editorTool={editorTool}
objects={objects} objects={objects}
filters={filters} filters={filters}
mapSize={mapSize} mapSize={mapSize}
@ -397,9 +447,12 @@ export default function LocationMapManager() {
/> />
<LocationMapBottomSheet <LocationMapBottomSheet
mode={mode} mode={mode}
editorTool={editorTool}
canManage={canManage} canManage={canManage}
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
layersOpen={layersOpen}
setLayersOpen={setLayersOpen}
selectedObject={selectedObject} selectedObject={selectedObject}
selectedZoneItems={selectedZoneItems} selectedZoneItems={selectedZoneItems}
visibleUnmappedItems={visibleUnmappedItems} visibleUnmappedItems={visibleUnmappedItems}
@ -412,18 +465,30 @@ export default function LocationMapManager() {
onRedo={handleRedo} onRedo={handleRedo}
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
onPublish={handlePublish} onPublish={handlePublish}
onPanMode={() => setEditorTool("pan")}
onEditObjects={() => setEditorTool("edit")}
onEditMode={() => { onEditMode={() => {
setMode("edit"); setMode("edit");
setEditorTool("pan");
setPreviewDraft(true); setPreviewDraft(true);
}} }}
onObjectField={handleObjectField} onObjectField={handleObjectField}
onZoneLinkChange={handleZoneLinkChange} onZoneLinkChange={handleZoneLinkChange}
onDuplicateObject={handleDuplicateObject} onDuplicateObject={handleDuplicateObject}
onDeleteObject={handleDeleteObject} onDeleteObject={requestDeleteObject}
/> />
</> </>
)} )}
</main> </main>
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteObject)}
title={`Delete ${pendingDeleteObject?.label || "this map area"}?`}
description="This removes the area from the draft map. Save the draft after deleting to keep the change."
confirmLabel="Delete Area"
onClose={() => setPendingDeleteObject(null)}
onConfirm={handleDeleteObject}
/>
</div> </div>
); );
} }

View File

@ -1,15 +1,16 @@
.location-map-page { .location-map-page {
min-height: calc(100vh - 56px); height: calc(100dvh - 56px);
background: var(--color-bg-body); min-height: 0;
color: var(--color-text-primary); background: #07111d;
color: #f8fafc;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.location-map-topbar { .location-map-topbar {
position: sticky; position: relative;
top: 0; z-index: 25;
z-index: 20;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center; align-items: center;
@ -25,8 +26,8 @@
min-height: 40px; min-height: 40px;
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 8px; border-radius: 8px;
background: var(--color-bg-elevated); background: #15283b;
color: var(--color-text-primary); color: #f8fafc;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
} }
@ -41,7 +42,7 @@
.location-map-title h1 { .location-map-title h1 {
font-size: clamp(1rem, 3vw, 1.35rem); font-size: clamp(1rem, 3vw, 1.35rem);
color: var(--color-text-primary); color: #f8fafc;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -50,7 +51,7 @@
.location-map-title span { .location-map-title span {
display: block; display: block;
margin-top: 0.15rem; margin-top: 0.15rem;
color: var(--color-text-secondary); color: #9fb3c8;
font-size: 0.86rem; font-size: 0.86rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -62,7 +63,7 @@
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 999px; border-radius: 999px;
color: var(--color-text-primary); color: #f8fafc;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 800; font-weight: 800;
text-align: center; text-align: center;
@ -85,19 +86,33 @@
.location-map-workspace { .location-map-workspace {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
min-width: 0;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
} }
.location-map-toolbar { .location-map-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem clamp(0.75rem, 2vw, 1.25rem); padding: 0.75rem clamp(0.75rem, 2vw, 1.25rem);
border-bottom: 1px solid var(--color-border-light); border-bottom: 1px solid var(--color-border-light);
background: rgba(11, 19, 31, 0.98);
}
.location-map-mode-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
min-width: 0;
} }
.location-map-mode-buttons, .location-map-mode-buttons,
.location-map-tool-buttons,
.location-map-zoom-controls, .location-map-zoom-controls,
.location-map-editor-actions { .location-map-editor-actions {
display: flex; display: flex;
@ -106,6 +121,7 @@
} }
.location-map-mode-buttons button, .location-map-mode-buttons button,
.location-map-tool-buttons button,
.location-map-zoom-controls button, .location-map-zoom-controls button,
.location-map-editor-actions button, .location-map-editor-actions button,
.location-map-setup-actions button { .location-map-setup-actions button {
@ -113,18 +129,32 @@
padding: 0.55rem 0.8rem; padding: 0.55rem 0.8rem;
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 8px; border-radius: 8px;
background: var(--color-bg-elevated); background: #15283b;
color: var(--color-text-primary); color: #f8fafc;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
} }
.location-map-mode-buttons button.active, .location-map-mode-buttons button.active,
.location-map-editor-actions button:nth-last-child(2), .location-map-tool-buttons button.active,
.location-map-primary-actions button:nth-child(2),
.location-map-primary-actions button:nth-child(3),
.location-map-setup-actions .btn-primary { .location-map-setup-actions .btn-primary {
border-color: var(--color-primary); border-color: var(--color-primary);
background: var(--color-primary); background: var(--color-primary);
color: var(--color-text-inverse); color: #06111f;
}
.location-map-tool-buttons button.is-pan.active {
border-color: rgba(245, 158, 11, 0.78);
background: rgba(245, 158, 11, 0.22);
color: #f8fafc;
}
.location-map-tool-buttons button.is-edit.active {
border-color: rgb(45, 212, 191);
background: rgba(20, 184, 166, 0.24);
color: #f8fafc;
} }
.location-map-editor-actions button.danger { .location-map-editor-actions button.danger {
@ -133,6 +163,7 @@
} }
.location-map-mode-buttons button:disabled, .location-map-mode-buttons button:disabled,
.location-map-tool-buttons button:disabled,
.location-map-zoom-controls button:disabled, .location-map-zoom-controls button:disabled,
.location-map-editor-actions button:disabled, .location-map-editor-actions button:disabled,
.location-map-setup-actions button:disabled { .location-map-setup-actions button:disabled {
@ -142,7 +173,7 @@
.location-map-zoom-controls span { .location-map-zoom-controls span {
min-width: 54px; min-width: 54px;
color: var(--color-text-secondary); color: #cbd5e1;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
text-align: center; text-align: center;
@ -150,18 +181,25 @@
.location-map-canvas-shell { .location-map-canvas-shell {
min-height: 0; min-height: 0;
min-width: 0;
max-width: 100vw;
overflow: hidden;
padding: 0.75rem; padding: 0.75rem;
} }
.location-map-scroll { .location-map-scroll {
position: relative;
width: 100%;
max-width: 100%;
height: 100%; height: 100%;
min-height: 48vh; min-height: 0;
overflow: auto; overflow: auto;
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 8px; border-radius: 8px;
background: background:
linear-gradient(135deg, rgba(20, 184, 166, 0.08), transparent 40%), linear-gradient(135deg, rgba(20, 184, 166, 0.08), transparent 40%),
linear-gradient(180deg, rgba(15, 23, 42, 0.2), rgba(15, 23, 42, 0.04)); linear-gradient(180deg, rgba(15, 23, 42, 0.2), rgba(15, 23, 42, 0.04));
background-color: #101b2a;
touch-action: pan-x pan-y; touch-action: pan-x pan-y;
} }
@ -171,6 +209,10 @@
display: block; display: block;
} }
.location-map-svg.is-editable {
touch-action: none;
}
.location-map-background { .location-map-background {
fill: rgba(15, 23, 34, 0.92); fill: rgba(15, 23, 34, 0.92);
stroke: rgba(148, 163, 184, 0.28); stroke: rgba(148, 163, 184, 0.28);
@ -188,16 +230,25 @@
stroke: rgba(96, 165, 250, 0.72); stroke: rgba(96, 165, 250, 0.72);
stroke-width: 2; stroke-width: 2;
cursor: pointer; cursor: pointer;
transition: fill 120ms ease, stroke 120ms ease, stroke-width 120ms ease;
}
.location-map-object.is-editable rect:first-child {
cursor: grab;
}
.location-map-object.is-editable rect:first-child:active {
cursor: grabbing;
} }
.location-map-object.is-selected rect:first-child { .location-map-object.is-selected rect:first-child {
fill: rgba(20, 184, 166, 0.26); fill: rgba(20, 184, 166, 0.34);
stroke: rgb(45, 212, 191); stroke: rgb(45, 212, 191);
stroke-width: 4; stroke-width: 4;
} }
.location-map-label { .location-map-label {
fill: var(--color-text-primary); fill: #f8fafc;
font-size: 24px; font-size: 24px;
font-weight: 800; font-weight: 800;
pointer-events: none; pointer-events: none;
@ -222,6 +273,23 @@
stroke: rgba(15, 23, 42, 0.9); stroke: rgba(15, 23, 42, 0.9);
stroke-width: 2; stroke-width: 2;
cursor: nwse-resize; cursor: nwse-resize;
touch-action: none;
}
.location-map-empty-canvas {
position: sticky;
left: 50%;
bottom: 1rem;
width: fit-content;
max-width: calc(100% - 2rem);
transform: translateX(-50%);
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border-light);
border-radius: 999px;
background: rgba(17, 28, 42, 0.9);
color: #cbd5e1;
font-weight: 800;
pointer-events: none;
} }
.location-map-bottom-sheet { .location-map-bottom-sheet {
@ -236,13 +304,46 @@
.location-map-sheet-header { .location-map-sheet-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 0.65rem; margin-bottom: 0.65rem;
} }
.location-map-sheet-header > div {
min-width: 0;
}
.location-map-sheet-header strong {
display: block;
color: #f8fafc;
}
.location-map-sheet-header > div span {
display: block;
margin-top: 0.2rem;
color: #9fb3c8;
font-size: 0.82rem;
}
.location-map-sheet-header span, .location-map-sheet-header span,
.location-map-muted { .location-map-muted {
color: var(--color-text-secondary); color: #9fb3c8;
}
.location-map-layer-toggle {
min-height: 40px;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border-light);
border-radius: 999px;
background: rgba(15, 23, 34, 0.72);
color: #f8fafc;
font-weight: 800;
cursor: pointer;
}
.location-map-layer-toggle.active {
border-color: var(--color-primary);
background: rgba(59, 130, 246, 0.22);
} }
.location-map-display-controls { .location-map-display-controls {
@ -253,7 +354,7 @@
} }
.location-map-display-controls label { .location-map-display-controls label {
min-height: 34px; min-height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
@ -261,7 +362,7 @@
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 8px; border-radius: 8px;
background: rgba(15, 23, 34, 0.58); background: rgba(15, 23, 34, 0.58);
color: var(--color-text-primary); color: #f8fafc;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 700; font-weight: 700;
} }
@ -276,6 +377,31 @@
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.location-map-primary-actions button {
flex: 1 1 96px;
}
.location-map-mobile-tool-switch {
display: none;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
margin-bottom: 0.75rem;
}
.location-map-mobile-tool-switch button {
min-height: 40px;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: #15283b;
color: #f8fafc;
font-weight: 800;
}
.location-map-mobile-tool-switch button.active {
border-color: rgb(45, 212, 191);
background: rgba(20, 184, 166, 0.24);
}
.location-map-object-form { .location-map-object-form {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
@ -284,7 +410,7 @@
.location-map-object-form label { .location-map-object-form label {
display: grid; display: grid;
gap: 0.3rem; gap: 0.3rem;
color: var(--color-text-secondary); color: #9fb3c8;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 800; font-weight: 800;
} }
@ -295,8 +421,8 @@
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 8px; border-radius: 8px;
background: var(--color-bg-elevated); background: #15283b;
color: var(--color-text-primary); color: #f8fafc;
} }
.location-map-object-grid { .location-map-object-grid {
@ -306,10 +432,28 @@
} }
.location-map-zone-items h2 { .location-map-zone-items h2 {
margin-bottom: 0.5rem;
font-size: 1rem; font-size: 1rem;
} }
.location-map-zone-items-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.55rem;
}
.location-map-zone-items-title span {
min-width: 34px;
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: rgba(96, 165, 250, 0.18);
color: #f8fafc;
font-size: 0.82rem;
font-weight: 900;
text-align: center;
}
.location-map-zone-items ul, .location-map-zone-items ul,
.location-map-unmapped-list ul { .location-map-unmapped-list ul {
display: grid; display: grid;
@ -327,10 +471,11 @@
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 8px; border-radius: 8px;
background: rgba(15, 23, 34, 0.56); background: rgba(15, 23, 34, 0.56);
color: #f8fafc;
} }
.location-map-zone-items small { .location-map-zone-items small {
color: var(--color-text-secondary); color: #cbd5e1;
font-weight: 800; font-weight: 800;
} }
@ -350,7 +495,7 @@
padding: 1.15rem; padding: 1.15rem;
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
border-radius: 8px; border-radius: 8px;
background: var(--color-bg-surface); background: #111c2a;
} }
.location-map-setup h2 { .location-map-setup h2 {
@ -360,7 +505,7 @@
.location-map-setup p { .location-map-setup p {
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--color-text-secondary); color: #9fb3c8;
} }
.location-map-setup-actions { .location-map-setup-actions {
@ -370,7 +515,7 @@
.location-map-loading { .location-map-loading {
margin: 3rem auto; margin: 3rem auto;
color: var(--color-text-secondary); color: #9fb3c8;
} }
@media (min-width: 840px) { @media (min-width: 840px) {
@ -391,6 +536,14 @@
} }
@media (max-width: 520px) { @media (max-width: 520px) {
.location-map-canvas-shell {
padding: 0.5rem;
}
.location-map-bottom-sheet {
max-height: 38dvh;
}
.location-map-topbar { .location-map-topbar {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
} }
@ -402,6 +555,28 @@
.location-map-toolbar { .location-map-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch;
}
.location-map-mode-group,
.location-map-mode-buttons,
.location-map-tool-buttons,
.location-map-zoom-controls {
width: 100%;
}
.location-map-mode-buttons button,
.location-map-tool-buttons button,
.location-map-zoom-controls button {
flex: 1 1 0;
}
.location-map-tool-buttons {
display: none;
}
.location-map-mobile-tool-switch {
display: grid;
} }
.location-map-display-controls { .location-map-display-controls {

View File

@ -234,6 +234,8 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({
await page.getByRole("button", { name: "Create Map From Existing Zones" }).click(); await page.getByRole("button", { name: "Create Map From Existing Zones" }).click();
await expect(page.getByText("Bakery")).toBeVisible(); await expect(page.getByText("Bakery")).toBeVisible();
await expect(page.getByText("Frozen Foods")).toBeVisible(); await expect(page.getByText("Frozen Foods")).toBeVisible();
await expect(page.getByRole("button", { name: "Pan Mode" })).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Objects" })).toBeVisible();
await expect(page.locator(".location-map-pin")).toHaveCount(0); await expect(page.locator(".location-map-pin")).toHaveCount(0);
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
@ -258,6 +260,7 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({
await expect(page.getByText("sourdough")).toBeVisible(); await expect(page.getByText("sourdough")).toBeVisible();
await expect(page.getByText("frozen salmon")).toHaveCount(0); await expect(page.getByText("frozen salmon")).toHaveCount(0);
await page.getByRole("button", { name: "Layers" }).click();
await page.getByLabel("Unmapped").check(); await page.getByLabel("Unmapped").check();
await expect(page.getByText("loose batteries")).toBeVisible(); await expect(page.getByText("loose batteries")).toBeVisible();
}); });