feat: polish location map editor
This commit is contained in:
parent
78893545b2
commit
c100174ea8
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user