This commit is contained in:
AlacrisDevs
2026-02-09 00:23:35 +02:00
parent 9cb047c8b6
commit 38a0c2274d
3 changed files with 485 additions and 16 deletions

View File

@@ -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>