diff --git a/messages/en.json b/messages/en.json index 361a6ee..76bc6e1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -715,5 +715,10 @@ "map_description": "Description", "map_color": "Color", "map_pen_hint_points": "{count} point(s)", - "map_pen_hint_close": "click near first point to close, or press Esc to cancel" + "map_pen_hint_close": "click near first point to close, or press Esc to cancel", + "map_show_pin_labels": "Show pin labels", + "map_show_shape_labels": "Show shape labels", + "map_address": "Address", + "map_address_placeholder": "e.g., Tallinn, Estonia", + "map_change_address": "Change address" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index 82dd910..0793396 100644 --- a/messages/et.json +++ b/messages/et.json @@ -725,5 +725,10 @@ "map_description": "Kirjeldus", "map_color": "Värv", "map_pen_hint_points": "{count} punkt(i)", - "map_pen_hint_close": "kliki esimese punkti lähedal sulgemiseks või vajuta Esc tühistamiseks" + "map_pen_hint_close": "kliki esimese punkti lähedal sulgemiseks või vajuta Esc tühistamiseks", + "map_show_pin_labels": "Näita markerite silte", + "map_show_shape_labels": "Näita kujundite silte", + "map_address": "Aadress", + "map_address_placeholder": "nt. Tallinn, Eesti", + "map_change_address": "Muuda aadressi" } \ No newline at end of file diff --git a/src/lib/components/modules/MapWidget.svelte b/src/lib/components/modules/MapWidget.svelte index 05cfbea..3d5109b 100644 --- a/src/lib/components/modules/MapWidget.svelte +++ b/src/lib/components/modules/MapWidget.svelte @@ -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(null); + let addressValue = $state(""); // Layer context menu let layerCtx = $state<{ @@ -98,6 +104,15 @@ let renameLayerId = $state(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(null); + let dragObjType = $state<"pin" | "shape" | null>(null); + let dropTargetId = $state(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 @@ > +
+ + +
{#if activeLayer} - {#each activeLayer.pins as pin (pin.id)} + {#each [...activeLayer.pins].reverse() as pin (pin.id)} + + +
+ +{/if} + + + (showAddressModal = false)} + title={m.map_change_address()} +> +
+ { + if (e.key === "Enter") handleChangeAddress(); + }} + /> +
+ + +
+
+
+ { 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; + }