UX ifxes
This commit is contained in:
@@ -86,6 +86,12 @@
|
||||
// Layer modal
|
||||
let showLayerModal = $state(false);
|
||||
let layerName = $state("");
|
||||
let layerAddress = $state("");
|
||||
|
||||
// Address change modal
|
||||
let showAddressModal = $state(false);
|
||||
let addressLayerId = $state<string | null>(null);
|
||||
let addressValue = $state("");
|
||||
|
||||
// Layer context menu
|
||||
let layerCtx = $state<{
|
||||
@@ -98,6 +104,15 @@
|
||||
let renameLayerId = $state<string | null>(null);
|
||||
let renameValue = $state("");
|
||||
|
||||
// Object context menu (right-click on pin/shape)
|
||||
let objCtx = $state<{
|
||||
id: string;
|
||||
type: "pin" | "shape";
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Image modal
|
||||
let showImageModal = $state(false);
|
||||
let imageUrlInput = $state("");
|
||||
@@ -114,7 +129,18 @@
|
||||
let rectStart: any = null;
|
||||
let rectPreview: any = null;
|
||||
|
||||
// Shape drag state
|
||||
// Label visibility
|
||||
let showPinLabels = $state(false);
|
||||
let showShapeLabels = $state(false);
|
||||
let leafletLabels: any[] = [];
|
||||
|
||||
// Object panel drag-reorder state
|
||||
let dragObjId = $state<string | null>(null);
|
||||
let dragObjType = $state<"pin" | "shape" | null>(null);
|
||||
let dropTargetId = $state<string | null>(null);
|
||||
let dropPosition = $state<"before" | "after" | null>(null);
|
||||
|
||||
// Shape drag state (map canvas)
|
||||
let draggingShapeId: string | null = null;
|
||||
let dragStartLatLng: any = null;
|
||||
|
||||
@@ -520,6 +546,8 @@
|
||||
leafletShapes.clear();
|
||||
leafletBoundsRects.forEach((r) => r.remove());
|
||||
leafletBoundsRects.clear();
|
||||
leafletLabels.forEach((l) => l.remove());
|
||||
leafletLabels = [];
|
||||
|
||||
// Pins
|
||||
(layer.pins ?? []).forEach((pin) => {
|
||||
@@ -566,10 +594,34 @@
|
||||
}
|
||||
});
|
||||
marker.on("click", () => selectObject(pin.id, "pin"));
|
||||
marker.on("contextmenu", (e: any) => {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
L.DomEvent.preventDefault(e);
|
||||
objCtx = {
|
||||
id: pin.id,
|
||||
type: "pin",
|
||||
label: pin.label,
|
||||
x: e.originalEvent.clientX,
|
||||
y: e.originalEvent.clientY,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
leafletMarkers.set(pin.id, marker);
|
||||
|
||||
if (showPinLabels && pin.label) {
|
||||
const lbl = L.tooltip({
|
||||
permanent: true,
|
||||
direction: "bottom",
|
||||
offset: [0, 0],
|
||||
className: "map-obj-label",
|
||||
})
|
||||
.setLatLng([pin.lat, pin.lng])
|
||||
.setContent(pin.label)
|
||||
.addTo(map);
|
||||
leafletLabels.push(lbl);
|
||||
}
|
||||
|
||||
// Bounds rect for pin
|
||||
if (
|
||||
pin.bounds_north != null &&
|
||||
@@ -623,9 +675,33 @@
|
||||
dragStartLatLng = e.latlng;
|
||||
map.dragging.disable();
|
||||
});
|
||||
poly.on("contextmenu", (e: any) => {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
L.DomEvent.preventDefault(e);
|
||||
objCtx = {
|
||||
id: shape.id,
|
||||
type: "shape",
|
||||
label: shape.label || shape.shape_type,
|
||||
x: e.originalEvent.clientX,
|
||||
y: e.originalEvent.clientY,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
leafletShapes.set(shape.id, poly);
|
||||
|
||||
if (showShapeLabels && shape.label) {
|
||||
const center = poly.getBounds().getCenter();
|
||||
const lbl = L.tooltip({
|
||||
permanent: true,
|
||||
direction: "center",
|
||||
className: "map-obj-label",
|
||||
})
|
||||
.setLatLng(center)
|
||||
.setContent(shape.label)
|
||||
.addTo(map);
|
||||
leafletLabels.push(lbl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -832,6 +908,11 @@
|
||||
layers = [...layers, withData];
|
||||
activeLayerIdx = layers.length - 1;
|
||||
showLayerOnMap(withData);
|
||||
const addr = layerAddress.trim() || venueAddress;
|
||||
if (type === "osm" && addr) {
|
||||
geocodeAddress(addr);
|
||||
}
|
||||
layerAddress = "";
|
||||
setupRealtime();
|
||||
} catch {
|
||||
toasts.error("Failed to create layer");
|
||||
@@ -892,6 +973,180 @@
|
||||
renameValue = "";
|
||||
}
|
||||
|
||||
function startChangeAddress() {
|
||||
if (!layerCtx) return;
|
||||
addressLayerId = layerCtx.id;
|
||||
addressValue = "";
|
||||
showAddressModal = true;
|
||||
closeLayerContextMenu();
|
||||
}
|
||||
|
||||
async function handleChangeAddress() {
|
||||
const addr = addressValue.trim() || venueAddress;
|
||||
if (!addressLayerId || !addr) return;
|
||||
showAddressModal = false;
|
||||
|
||||
// Switch to the target layer
|
||||
const idx = layers.findIndex((l) => l.id === addressLayerId);
|
||||
if (idx >= 0 && idx !== activeLayerIdx) {
|
||||
activeLayerIdx = idx;
|
||||
showLayerOnMap(layers[idx]);
|
||||
}
|
||||
|
||||
await geocodeAddress(addr);
|
||||
addressLayerId = null;
|
||||
addressValue = "";
|
||||
}
|
||||
|
||||
function closeObjCtx() {
|
||||
objCtx = null;
|
||||
}
|
||||
|
||||
function ctxEditObject() {
|
||||
if (!objCtx) return;
|
||||
const { id, type } = objCtx;
|
||||
closeObjCtx();
|
||||
if (type === "pin") {
|
||||
const pin = activeLayer?.pins.find((p) => p.id === id);
|
||||
if (pin) openEditPin(pin);
|
||||
} else {
|
||||
const shape = (activeLayer?.shapes ?? []).find((s) => s.id === id);
|
||||
if (shape) openEditShape(shape);
|
||||
}
|
||||
}
|
||||
|
||||
function ctxDeleteObject() {
|
||||
if (!objCtx) return;
|
||||
const { id, type } = objCtx;
|
||||
closeObjCtx();
|
||||
if (type === "pin") {
|
||||
handleDeletePin(id);
|
||||
} else {
|
||||
handleDeleteShape(id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleObjDragOver(e: DragEvent, targetId: string) {
|
||||
e.preventDefault();
|
||||
if (!dragObjId || targetId === dragObjId) {
|
||||
dropTargetId = null;
|
||||
dropPosition = null;
|
||||
return;
|
||||
}
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
dropTargetId = targetId;
|
||||
dropPosition = e.clientY < midY ? "before" : "after";
|
||||
}
|
||||
|
||||
async function handleObjDrop() {
|
||||
if (
|
||||
!dragObjId ||
|
||||
!dragObjType ||
|
||||
!dropTargetId ||
|
||||
!dropPosition ||
|
||||
!activeLayer
|
||||
) {
|
||||
resetObjDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a combined ordered list: pins first, then shapes
|
||||
type ObjItem = { id: string; type: "pin" | "shape" };
|
||||
const items: ObjItem[] = [
|
||||
...activeLayer.pins.map((p) => ({
|
||||
id: p.id,
|
||||
type: "pin" as const,
|
||||
})),
|
||||
...(activeLayer.shapes ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
type: "shape" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
const fromIdx = items.findIndex((i) => i.id === dragObjId);
|
||||
let toIdx = items.findIndex((i) => i.id === dropTargetId);
|
||||
if (fromIdx < 0 || toIdx < 0) {
|
||||
resetObjDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove dragged item and insert at target position
|
||||
const [moved] = items.splice(fromIdx, 1);
|
||||
toIdx = items.findIndex((i) => i.id === dropTargetId);
|
||||
if (toIdx < 0) toIdx = items.length;
|
||||
if (dropPosition === "after") toIdx++;
|
||||
items.splice(toIdx, 0, moved);
|
||||
|
||||
// Split back into pins and shapes with new sort_order
|
||||
const newPins = items.filter((i) => i.type === "pin");
|
||||
const newShapes = items.filter((i) => i.type === "shape");
|
||||
|
||||
const oldPins = activeLayer.pins;
|
||||
const oldShapes = activeLayer.shapes ?? [];
|
||||
|
||||
layers = layers.map((l, i) =>
|
||||
i === activeLayerIdx
|
||||
? {
|
||||
...l,
|
||||
pins: newPins
|
||||
.map((np, idx) => {
|
||||
const pin = l.pins.find((p) => p.id === np.id);
|
||||
return pin
|
||||
? { ...pin, sort_order: idx }
|
||||
: l.pins[idx];
|
||||
})
|
||||
.filter(Boolean),
|
||||
shapes: newShapes
|
||||
.map((ns, idx) => {
|
||||
const shape = (l.shapes ?? []).find(
|
||||
(s) => s.id === ns.id,
|
||||
);
|
||||
return shape
|
||||
? { ...shape, sort_order: idx }
|
||||
: (l.shapes ?? [])[idx];
|
||||
})
|
||||
.filter(Boolean),
|
||||
}
|
||||
: l,
|
||||
);
|
||||
syncAllObjects(layers[activeLayerIdx]);
|
||||
|
||||
// Only persist sort_order for objects whose order actually changed
|
||||
const changedPins = newPins.filter((np, idx) => {
|
||||
const old = oldPins.findIndex((p) => p.id === np.id);
|
||||
return old !== idx;
|
||||
});
|
||||
const changedShapes = newShapes.filter((ns, idx) => {
|
||||
const old = oldShapes.findIndex((s) => s.id === ns.id);
|
||||
return old !== idx;
|
||||
});
|
||||
if (changedPins.length > 0 || changedShapes.length > 0) {
|
||||
const updates = [
|
||||
...changedPins.map((np, _i) => {
|
||||
const idx = newPins.indexOf(np);
|
||||
return updateMapPin(supabase, np.id, { sort_order: idx });
|
||||
}),
|
||||
...changedShapes.map((ns, _i) => {
|
||||
const idx = newShapes.indexOf(ns);
|
||||
return updateMapShape(supabase, ns.id, { sort_order: idx });
|
||||
}),
|
||||
];
|
||||
Promise.all(updates).catch(() =>
|
||||
toasts.error("Failed to reorder objects"),
|
||||
);
|
||||
}
|
||||
|
||||
resetObjDrag();
|
||||
}
|
||||
|
||||
function resetObjDrag() {
|
||||
dragObjId = null;
|
||||
dragObjType = null;
|
||||
dropTargetId = null;
|
||||
dropPosition = null;
|
||||
}
|
||||
|
||||
function ctxDeleteLayer() {
|
||||
if (!layerCtx) return;
|
||||
const id = layerCtx.id;
|
||||
@@ -1011,11 +1266,11 @@
|
||||
let layerId = payload.new?.layer_id ?? payload.old?.layer_id;
|
||||
|
||||
if (payload.event === "DELETE") {
|
||||
if (!layerId)
|
||||
layerId = layers.find((l) =>
|
||||
l.pins.some((p) => p.id === id),
|
||||
)?.id;
|
||||
if (!layerId) return;
|
||||
const resolvedLayerId =
|
||||
layerId ??
|
||||
layers.find((l) => l.pins.some((p) => p.id === id))?.id;
|
||||
if (!resolvedLayerId) return;
|
||||
layerId = resolvedLayerId;
|
||||
layers = layers.map((l) =>
|
||||
l.id === layerId
|
||||
? { ...l, pins: l.pins.filter((p) => p.id !== id) }
|
||||
@@ -1065,13 +1320,14 @@
|
||||
let layerId = payload.new?.layer_id ?? payload.old?.layer_id;
|
||||
|
||||
if (payload.event === "DELETE") {
|
||||
if (!layerId)
|
||||
layerId = layers.find((l) =>
|
||||
(l.shapes ?? []).some((s) => s.id === id),
|
||||
)?.id;
|
||||
if (!layerId) return;
|
||||
const resolvedLayerId =
|
||||
layerId ??
|
||||
layers.find((l) => (l.shapes ?? []).some((s) => s.id === id))
|
||||
?.id;
|
||||
if (!resolvedLayerId) return;
|
||||
layerId = resolvedLayerId;
|
||||
layers = layers.map((l) =>
|
||||
l.id === layerId
|
||||
l.id === resolvedLayerId
|
||||
? {
|
||||
...l,
|
||||
shapes: (l.shapes ?? []).filter((s) => s.id !== id),
|
||||
@@ -1407,15 +1663,69 @@
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 px-3 py-1.5 border-b border-light/10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="label-toggle {showPinLabels ? 'active' : ''}"
|
||||
onclick={() => {
|
||||
showPinLabels = !showPinLabels;
|
||||
if (activeLayer) syncAllObjects(activeLayer);
|
||||
}}
|
||||
title={m.map_show_pin_labels()}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style={icon("", 13)}>location_on</span
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style={icon("", 11)}>label</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="label-toggle {showShapeLabels ? 'active' : ''}"
|
||||
onclick={() => {
|
||||
showShapeLabels = !showShapeLabels;
|
||||
if (activeLayer) syncAllObjects(activeLayer);
|
||||
}}
|
||||
title={m.map_show_shape_labels()}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style={icon("", 13)}>crop_landscape</span
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style={icon("", 11)}>label</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if activeLayer}
|
||||
<!-- Pins -->
|
||||
{#each activeLayer.pins as pin (pin.id)}
|
||||
{#each [...activeLayer.pins].reverse() as pin (pin.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="obj-row {selectedObjectId === pin.id
|
||||
? 'selected'
|
||||
: ''} {dropTargetId === pin.id &&
|
||||
dropPosition === 'before'
|
||||
? 'drop-before'
|
||||
: ''} {dropTargetId === pin.id &&
|
||||
dropPosition === 'after'
|
||||
? 'drop-after'
|
||||
: ''}"
|
||||
draggable={isEditor}
|
||||
ondragstart={() => {
|
||||
dragObjId = pin.id;
|
||||
dragObjType = "pin";
|
||||
}}
|
||||
ondragover={(e) => handleObjDragOver(e, pin.id)}
|
||||
ondragend={resetObjDrag}
|
||||
ondrop={handleObjDrop}
|
||||
onclick={() => selectObject(pin.id, "pin")}
|
||||
>
|
||||
<span
|
||||
@@ -1462,12 +1772,27 @@
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Shapes -->
|
||||
{#each activeLayer.shapes ?? [] as shape (shape.id)}
|
||||
{#each [...(activeLayer.shapes ?? [])].reverse() as shape (shape.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="obj-row {selectedObjectId === shape.id
|
||||
? 'selected'
|
||||
: ''} {dropTargetId === shape.id &&
|
||||
dropPosition === 'before'
|
||||
? 'drop-before'
|
||||
: ''} {dropTargetId === shape.id &&
|
||||
dropPosition === 'after'
|
||||
? 'drop-after'
|
||||
: ''}"
|
||||
draggable={isEditor}
|
||||
ondragstart={() => {
|
||||
dragObjId = shape.id;
|
||||
dragObjType = "shape";
|
||||
}}
|
||||
ondragover={(e) =>
|
||||
handleObjDragOver(e, shape.id)}
|
||||
ondragend={resetObjDrag}
|
||||
ondrop={handleObjDrop}
|
||||
onclick={() => selectObject(shape.id, "shape")}
|
||||
>
|
||||
<span
|
||||
@@ -1731,6 +2056,12 @@
|
||||
bind:value={layerName}
|
||||
placeholder="e.g., Floor 1, Parking Area"
|
||||
/>
|
||||
<Input
|
||||
variant="compact"
|
||||
label={m.map_address()}
|
||||
bind:value={layerAddress}
|
||||
placeholder={venueAddress || m.map_address_placeholder()}
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1795,6 +2126,12 @@
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button type="button" class="ctx-item" onclick={startChangeAddress}>
|
||||
<span class="material-symbols-rounded" style={icon("", 16)}
|
||||
>location_on</span
|
||||
>
|
||||
{m.map_change_address()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ctx-item danger"
|
||||
@@ -1809,6 +2146,47 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Object context menu (right-click on pin/shape) -->
|
||||
{#if objCtx}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50"
|
||||
onclick={closeObjCtx}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
closeObjCtx();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="layer-ctx-menu"
|
||||
style="left:{objCtx.x}px;top:{objCtx.y}px"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
class="px-2.5 py-1.5 text-[10px] text-light/30 uppercase tracking-wider truncate max-w-[160px]"
|
||||
>
|
||||
{objCtx.label}
|
||||
</div>
|
||||
<button type="button" class="ctx-item" onclick={ctxEditObject}>
|
||||
<span class="material-symbols-rounded" style={icon("", 16)}
|
||||
>edit</span
|
||||
>
|
||||
{objCtx.type === "pin" ? m.map_edit_pin() : m.map_edit_shape()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ctx-item danger"
|
||||
onclick={ctxDeleteObject}
|
||||
>
|
||||
<span class="material-symbols-rounded" style={icon("", 16)}
|
||||
>delete</span
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rename Layer Modal -->
|
||||
<Modal
|
||||
isOpen={showRenameModal}
|
||||
@@ -1843,9 +2221,47 @@
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Change Address Modal -->
|
||||
<Modal
|
||||
isOpen={showAddressModal}
|
||||
onClose={() => (showAddressModal = false)}
|
||||
title={m.map_change_address()}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Input
|
||||
variant="compact"
|
||||
label={m.map_address()}
|
||||
bind:value={addressValue}
|
||||
placeholder={venueAddress || m.map_address_placeholder()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") handleChangeAddress();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
|
||||
onclick={() => (showAddressModal = false)}>Cancel</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!addressValue.trim() && !venueAddress}
|
||||
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={handleChangeAddress}>Go</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (objCtx) {
|
||||
closeObjCtx();
|
||||
return;
|
||||
}
|
||||
if (layerCtx) {
|
||||
closeLayerContextMenu();
|
||||
return;
|
||||
@@ -1874,6 +2290,20 @@
|
||||
background: #0a1628 !important;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
:global(.map-obj-label) {
|
||||
background: rgba(0, 0, 0, 0.75) !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
color: #fff !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 500 !important;
|
||||
padding: 2px 6px !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
:global(.map-obj-label::before) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cursor-crosshair {
|
||||
cursor: crosshair;
|
||||
@@ -2023,6 +2453,12 @@
|
||||
background: rgba(0, 163, 224, 0.1);
|
||||
color: white;
|
||||
}
|
||||
.obj-row.drop-before {
|
||||
box-shadow: inset 0 2px 0 0 #00a3e0;
|
||||
}
|
||||
.obj-row.drop-after {
|
||||
box-shadow: inset 0 -2px 0 0 #00a3e0;
|
||||
}
|
||||
|
||||
.obj-action {
|
||||
opacity: 0;
|
||||
@@ -2079,4 +2515,27 @@
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Label toggle buttons */
|
||||
.label-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: none;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.label-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.label-toggle.active {
|
||||
background: rgba(0, 163, 224, 0.15);
|
||||
border-color: rgba(0, 163, 224, 0.3);
|
||||
color: #00a3e0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user