diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 7f911bb..042c989 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -1082,10 +1082,6 @@ class FolkMapViewer extends HTMLElement { }); this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right"); - this.map.addControl(new (window as any).maplibregl.GeolocateControl({ - positionOptions: { enableHighAccuracy: true }, - trackUserLocation: false, - }), "top-right"); // Apply dark mode inversion filter to OSM tiles this.applyDarkFilter(); @@ -1158,7 +1154,7 @@ class FolkMapViewer extends HTMLElement { const reqRoom = event.data.data?.roomSlug; if (reqRoom === this.room && this.sharingLocation) { // Already sharing — sync will propagate automatically - } else if (reqRoom === this.room && !this.privacySettings.ghostMode) { + } else if (reqRoom === this.room && this.privacySettings.precision !== "hidden") { // Not sharing yet — start sharing in response to ping this.toggleLocationSharing(); } @@ -1225,62 +1221,93 @@ class FolkMapViewer extends HTMLElement { el.title = `${p.name} - ${p.status} (${ageLabel})`; } } else { + const isSelf = id === this.participantId; const dark = this.isDarkTheme(); const markerBg = dark ? '#1a1a2e' : '#fafaf7'; const textShadow = dark ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.3)'; const el = document.createElement("div"); el.className = "participant-marker"; - el.style.cssText = ` - width: 36px; height: 36px; border-radius: 50%; - border: 3px solid ${isStale ? "#6b7280" : p.color}; background: ${markerBg}; - display: flex; align-items: center; justify-content: center; - font-size: 18px; cursor: pointer; position: relative; - box-shadow: 0 0 8px ${p.color}60; - opacity: ${isStale ? "0.5" : "1"}; - transition: opacity 0.3s; - `; - // Emoji span with class for later updates - const emojiSpan = document.createElement("span"); - emojiSpan.className = "marker-emoji"; - emojiSpan.textContent = p.emoji; - el.appendChild(emojiSpan); + if (isSelf) { + // Self-marker: pulsing blue dot + el.dataset.selfMarker = "true"; + el.style.cssText = ` + width: 20px; height: 20px; border-radius: 50%; + background: #4285f4; border: 3px solid #fff; + cursor: pointer; position: relative; + box-shadow: 0 0 8px rgba(66,133,244,0.6); + `; + // Animated pulse ring + const ring = document.createElement("div"); + ring.style.cssText = ` + position: absolute; top: 50%; left: 50%; + width: 36px; height: 36px; border-radius: 50%; + border: 2px solid #4285f4; opacity: 0; + transform: translate(-50%, -50%); + animation: selfPulse 2s ease-out infinite; + pointer-events: none; + `; + el.appendChild(ring); + } else { + el.style.cssText = ` + width: 36px; height: 36px; border-radius: 50%; + border: 3px solid ${isStale ? "#6b7280" : p.color}; background: ${markerBg}; + display: flex; align-items: center; justify-content: center; + font-size: 18px; cursor: pointer; position: relative; + box-shadow: 0 0 8px ${p.color}60; + opacity: ${isStale ? "0.5" : "1"}; + transition: opacity 0.3s; + `; + + // Emoji span with class for later updates + const emojiSpan = document.createElement("span"); + emojiSpan.className = "marker-emoji"; + emojiSpan.textContent = p.emoji; + el.appendChild(emojiSpan); + } // Staleness tooltip const ageSec = Math.floor(ageMs / 1000); const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale"; - el.title = `${p.name} - ${p.status} (${ageLabel})`; + el.title = isSelf ? "You" : `${p.name} - ${p.status} (${ageLabel})`; // Heading arrow (CSS triangle) const arrow = document.createElement("div"); arrow.className = "heading-arrow"; + const arrowColor = isSelf ? "#4285f4" : p.color; arrow.style.cssText = ` - position: absolute; top: -6px; left: 50%; + position: absolute; top: ${isSelf ? "-8px" : "-6px"}; left: 50%; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; - border-bottom: 8px solid ${p.color}; + border-bottom: 8px solid ${arrowColor}; transform: translateX(-50%)${p.location.heading !== undefined ? ` rotate(${p.location.heading}deg)` : ""}; display: ${p.location.heading !== undefined ? "block" : "none"}; `; el.appendChild(arrow); - // Name label below - const label = document.createElement("div"); - label.className = "marker-label"; - label.style.cssText = ` - position: absolute; bottom: -18px; left: 50%; transform: translateX(-50%); - font-size: 10px; color: ${p.color}; font-weight: 600; - white-space: nowrap; text-shadow: 0 1px 3px ${textShadow}; - font-family: system-ui, sans-serif; - `; - label.textContent = p.name; - el.appendChild(label); + // Name label below (skip for self) + if (!isSelf) { + const label = document.createElement("div"); + label.className = "marker-label"; + label.style.cssText = ` + position: absolute; bottom: -18px; left: 50%; transform: translateX(-50%); + font-size: 10px; color: ${p.color}; font-weight: 600; + white-space: nowrap; text-shadow: 0 1px 3px ${textShadow}; + font-family: system-ui, sans-serif; + `; + label.textContent = p.name; + el.appendChild(label); + } el.addEventListener("click", () => { - this.selectedParticipant = id; - this.selectedWaypoint = null; - this.renderNavigationPanel(); + if (isSelf) { + this.locateMe(); + } else { + this.selectedParticipant = id; + this.selectedWaypoint = null; + this.renderNavigationPanel(); + } }); const marker = new (window as any).maplibregl.Marker({ element: el }) @@ -1334,10 +1361,7 @@ class FolkMapViewer extends HTMLElement { this.updateParticipantList(state); } - private updateParticipantList(state: RoomState) { - const list = this.shadow.getElementById("participant-list"); - if (!list) return; - + private buildParticipantHTML(state: RoomState): string { // Dedup by name (keep most recent) const byName = new Map(); for (const p of Object.values(state.participants)) { @@ -1347,18 +1371,15 @@ class FolkMapViewer extends HTMLElement { } } const entries = Array.from(byName.values()); - const myLoc = state.participants[this.participantId]?.location; - const statusColors: Record = { online: "#22c55e", away: "#f59e0b", ghost: "#64748b", offline: "#ef4444" }; - list.innerHTML = entries.map((p) => { + return entries.map((p) => { let distLabel = ""; if (myLoc && p.location && p.id !== this.participantId) { distLabel = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, p.location.latitude, p.location.longitude)); } const statusColor = statusColors[p.status] || "#64748b"; - // Staleness info let ageLabel = ""; let isStale = false; if (p.location) { @@ -1384,17 +1405,10 @@ class FolkMapViewer extends HTMLElement { ${p.id !== this.participantId ? `` : ""} `; }).join(""); + } - // Footer actions - list.insertAdjacentHTML("beforeend", ` -
- - -
- `); - - // Attach listeners - list.querySelectorAll("[data-ping]").forEach((btn) => { + private attachParticipantListeners(container: HTMLElement) { + container.querySelectorAll("[data-ping]").forEach((btn) => { btn.addEventListener("click", () => { const pid = (btn as HTMLElement).dataset.ping!; this.pushManager?.requestLocation(this.room, pid); @@ -1402,23 +1416,52 @@ class FolkMapViewer extends HTMLElement { setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000); }); }); - list.querySelectorAll("[data-nav-participant]").forEach((btn) => { + container.querySelectorAll("[data-nav-participant]").forEach((btn) => { btn.addEventListener("click", () => { this.selectedParticipant = (btn as HTMLElement).dataset.navParticipant!; this.selectedWaypoint = null; this.renderNavigationPanel(); }); }); - list.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => { + container.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => { this.showMeetingModal = true; this.renderMeetingPointModal(); }); - list.querySelector("#sidebar-import-btn")?.addEventListener("click", () => { + container.querySelector("#sidebar-import-btn")?.addEventListener("click", () => { this.showImportModal = true; this.renderImportModal(); }); } + private updateParticipantList(state: RoomState) { + const list = this.shadow.getElementById("participant-list"); + const mobileList = this.shadow.getElementById("participant-list-mobile"); + + const html = this.buildParticipantHTML(state); + const footerHTML = ` +
+ + +
+ `; + + // Desktop sidebar + if (list) { + list.innerHTML = html + footerHTML; + this.attachParticipantListeners(list); + } + + // Mobile bottom sheet + if (mobileList) { + mobileList.innerHTML = html; + this.attachParticipantListeners(mobileList); + // Update sheet header count + const header = this.shadow.querySelector(".sheet-header span:first-child"); + const count = Object.keys(state.participants).length; + if (header) header.textContent = `Participants (${count})`; + } + } + private updateMarkerTheme() { const dark = this.isDarkTheme(); const markerBg = dark ? '#1a1a2e' : '#fafaf7'; @@ -1426,6 +1469,8 @@ class FolkMapViewer extends HTMLElement { for (const marker of this.participantMarkers.values()) { const el = marker.getElement?.(); if (!el) continue; + // Skip self-marker (always blue) + if (el.dataset?.selfMarker) continue; el.style.background = markerBg; const label = el.querySelector('.marker-label') as HTMLElement | null; if (label) label.style.textShadow = `0 1px 3px ${textShadow}`; @@ -1487,7 +1532,7 @@ class FolkMapViewer extends HTMLElement { } private toggleLocationSharing() { - if (this.privacySettings.ghostMode) return; // Ghost mode prevents sharing + if (this.privacySettings.precision === "hidden") return; // Hidden/ghost mode prevents sharing if (this.sharingLocation) { if (this.watchId !== null) { @@ -1584,9 +1629,10 @@ class FolkMapViewer extends HTMLElement { ); } - private toggleGhostMode() { - this.privacySettings.ghostMode = !this.privacySettings.ghostMode; - if (this.privacySettings.ghostMode) { + private setPrecision(level: PrecisionLevel) { + this.privacySettings.precision = level; + this.privacySettings.ghostMode = level === "hidden"; + if (level === "hidden") { if (this.watchId !== null) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; @@ -1595,7 +1641,11 @@ class FolkMapViewer extends HTMLElement { this.sync?.updateStatus("ghost"); this.sync?.clearLocation(); } else { - this.sync?.updateStatus("online"); + if (this.privacySettings.ghostMode) { + // Was ghost, now switching to a visible level + this.sync?.updateStatus("online"); + } + this.privacySettings.ghostMode = false; } this.renderPrivacyPanel(); this.updateShareButton(); @@ -1604,8 +1654,8 @@ class FolkMapViewer extends HTMLElement { private updateShareButton() { const btn = this.shadow.getElementById("share-location"); if (!btn) return; - if (this.privacySettings.ghostMode) { - btn.textContent = "\u{1F47B} Ghost Mode"; + if (this.privacySettings.precision === "hidden") { + btn.textContent = "\u{1F47B} Hidden"; btn.classList.remove("sharing"); btn.classList.add("ghost"); } else if (this.sharingLocation) { @@ -1624,20 +1674,39 @@ class FolkMapViewer extends HTMLElement { permIndicator.style.background = colors[this.geoPermissionState] || "#64748b"; permIndicator.title = `Geolocation: ${this.geoPermissionState || "unknown"}`; } + // Also update mobile FAB + this.updateMobileFab(); } - private renderPrivacyPanel() { - const panel = this.shadow.getElementById("privacy-panel"); + private locateMe() { + if (this.sharingLocation) { + // Already sharing — fly to own location + const state = this.sync?.getState(); + const myLoc = state?.participants[this.participantId]?.location; + if (myLoc && this.map) { + this.map.flyTo({ center: [myLoc.longitude, myLoc.latitude], zoom: 16 }); + } + } else if (!this.privacySettings.ghostMode) { + // Not sharing — start sharing first + this.toggleLocationSharing(); + } + // Pulse the locate button + const btn = this.shadow.getElementById("locate-me-fab"); + if (btn) { + btn.style.background = "#4285f4"; + btn.style.color = "#fff"; + setTimeout(() => { btn.style.background = "var(--rs-bg-surface)"; btn.style.color = "var(--rs-text-secondary)"; }, 1500); + } + } + + private renderPrivacyPanel(container?: HTMLElement) { + const panel = container || this.shadow.getElementById("privacy-panel"); if (!panel) return; - // Use sub-component panel.innerHTML = ""; const privacyEl = document.createElement("map-privacy-panel") as any; privacyEl.settings = this.privacySettings; privacyEl.addEventListener("precision-change", (e: CustomEvent) => { - this.privacySettings.precision = e.detail; - }); - privacyEl.addEventListener("ghost-toggle", () => { - this.toggleGhostMode(); + this.setPrecision(e.detail as PrecisionLevel); }); panel.appendChild(privacyEl); } @@ -2037,6 +2106,108 @@ class FolkMapViewer extends HTMLElement { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } + @keyframes selfPulse { + 0% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.8); } + 70% { opacity: 0; transform: translate(-50%, -50%) scale(1.8); } + 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.8); } + } + + /* Locate-me FAB — always visible */ + .map-locate-fab { + position: fixed; bottom: 24px; left: 16px; z-index: 6; + width: 44px; height: 44px; border-radius: 50%; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border); + color: var(--rs-text-secondary); cursor: pointer; font-size: 20px; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); transition: all 0.2s; + } + .map-locate-fab:hover { border-color: #4285f4; color: #4285f4; } + + /* Mobile FAB menu — hidden on desktop */ + .mobile-fab-container { display: none; } + .mobile-bottom-sheet { display: none; } + + @media (max-width: 768px) { + .map-container { height: calc(100vh - 48px); min-height: 250px; max-height: none; border-radius: 0; border: none; } + .map-layout { flex-direction: column; } + .map-sidebar { display: none; } + .controls { display: none; } + #privacy-panel { display: none !important; } + .rapp-nav { position: absolute; top: 0; left: 0; right: 0; z-index: 7; background: var(--rs-bg-surface); border-bottom: 1px solid var(--rs-border); margin: 0; padding: 6px 12px; min-height: 48px; } + .map-locate-fab { bottom: 100px; } + + /* Mobile FAB menu */ + .mobile-fab-container { + display: block; position: fixed; bottom: 24px; right: 16px; z-index: 8; + } + .fab-main { + width: 52px; height: 52px; border-radius: 50%; + background: #4f46e5; border: none; color: #fff; cursor: pointer; + font-size: 22px; display: flex; align-items: center; justify-content: center; + box-shadow: 0 4px 16px rgba(79,70,229,0.5); transition: transform 0.2s; + } + .fab-main.open { transform: rotate(45deg); } + .fab-mini-list { + position: absolute; bottom: 62px; right: 2px; + display: flex; flex-direction: column-reverse; gap: 10px; + opacity: 0; pointer-events: none; transition: opacity 0.2s; + } + .fab-mini-list.open { opacity: 1; pointer-events: auto; } + .fab-mini { + display: flex; align-items: center; gap: 8px; flex-direction: row-reverse; + } + .fab-mini-btn { + width: 40px; height: 40px; border-radius: 50%; + border: 1px solid var(--rs-border); background: var(--rs-bg-surface); + color: var(--rs-text-primary); cursor: pointer; font-size: 16px; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + transform: scale(0); transition: transform 0.15s; + } + .fab-mini-list.open .fab-mini-btn { transform: scale(1); } + .fab-mini-list.open .fab-mini:nth-child(1) .fab-mini-btn { transition-delay: 0s; } + .fab-mini-list.open .fab-mini:nth-child(2) .fab-mini-btn { transition-delay: 0.04s; } + .fab-mini-list.open .fab-mini:nth-child(3) .fab-mini-btn { transition-delay: 0.08s; } + .fab-mini-list.open .fab-mini:nth-child(4) .fab-mini-btn { transition-delay: 0.12s; } + .fab-mini-list.open .fab-mini:nth-child(5) .fab-mini-btn { transition-delay: 0.16s; } + .fab-mini-label { + font-size: 11px; background: var(--rs-bg-surface); color: var(--rs-text-secondary); + padding: 4px 8px; border-radius: 6px; white-space: nowrap; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); border: 1px solid var(--rs-border); + } + .fab-mini-btn.sharing { border-color: #22c55e; color: #22c55e; } + .fab-mini-btn.ghost { border-color: #8b5cf6; color: #8b5cf6; } + + /* Mobile bottom sheet */ + .mobile-bottom-sheet { + display: block; position: fixed; bottom: 0; left: 0; right: 0; z-index: 7; + background: var(--rs-bg-surface); border-top: 1px solid var(--rs-border); + border-radius: 16px 16px 0 0; max-height: 60vh; + transition: transform 0.3s ease; transform: translateY(calc(100% - 40px)); + } + .mobile-bottom-sheet.expanded { transform: translateY(0); overflow-y: auto; } + .sheet-handle { + display: flex; align-items: center; justify-content: center; + padding: 10px; cursor: pointer; user-select: none; + } + .sheet-handle-bar { + width: 36px; height: 4px; border-radius: 2px; background: var(--rs-border-strong); + } + .sheet-header { + display: flex; align-items: center; justify-content: space-between; + padding: 0 16px 8px; font-size: 12px; font-weight: 600; + color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; + } + .sheet-content { padding: 0 16px 16px; } + + /* Mobile privacy popup */ + .mobile-privacy-popup { + position: fixed; bottom: 80px; right: 16px; z-index: 9; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); + border-radius: 12px; padding: 12px; width: 240px; + box-shadow: 0 8px 24px rgba(0,0,0,0.3); + } + } .share-link { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; @@ -2098,9 +2269,6 @@ class FolkMapViewer extends HTMLElement { .ping-btn:hover { border-color: #6366f1; color: #818cf8; } @media (max-width: 768px) { - .map-container { height: calc(100vh - 160px); min-height: 250px; max-height: none; } - .map-layout { flex-direction: column; } - .map-sidebar { width: 100%; max-height: 200px; } .room-history-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } } @@ -2193,13 +2361,58 @@ class FolkMapViewer extends HTMLElement { ${EMOJIS.map(e => ``).join("")} - + - +
+ + + + + +
+
+
+ + ${this.privacySettings.ghostMode ? "Ghost" : this.sharingLocation ? "Stop" : "Share"} +
+
+ + Privacy +
+
+ + Drop Pin +
+
+ + Share rMap +
+
+ + Emoji +
+
+ +
+ + +
+
+
+
+
+ Participants + tap to expand +
+
+
+ + + `; } @@ -2262,6 +2475,83 @@ class FolkMapViewer extends HTMLElement { }); }); + // Locate-me FAB + this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => { + this.locateMe(); + }); + + // Mobile FAB menu + this.shadow.getElementById("fab-main")?.addEventListener("click", () => { + const main = this.shadow.getElementById("fab-main"); + const list = this.shadow.getElementById("fab-mini-list"); + if (main && list) { + const isOpen = list.classList.contains("open"); + list.classList.toggle("open"); + main.classList.toggle("open"); + // Close mobile privacy popup when closing FAB + if (isOpen) { + const popup = this.shadow.getElementById("mobile-privacy-popup"); + if (popup) popup.style.display = "none"; + } + } + }); + + this.shadow.getElementById("fab-share-loc")?.addEventListener("click", () => { + this.toggleLocationSharing(); + this.updateMobileFab(); + }); + + this.shadow.getElementById("fab-privacy")?.addEventListener("click", () => { + const popup = this.shadow.getElementById("mobile-privacy-popup"); + if (popup) { + const isVisible = popup.style.display !== "none"; + popup.style.display = isVisible ? "none" : "block"; + if (!isVisible) this.renderPrivacyPanel(popup); + } + }); + + this.shadow.getElementById("fab-drop-pin")?.addEventListener("click", () => { + this.closeMobileFab(); + this.dropWaypoint(); + }); + + this.shadow.getElementById("fab-share-map")?.addEventListener("click", () => { + this.closeMobileFab(); + this.showShareModal = true; + this.renderShareModal(); + }); + + this.shadow.getElementById("fab-emoji")?.addEventListener("click", () => { + this.showEmojiPicker = !this.showEmojiPicker; + this.updateEmojiButton(); + }); + + // Mobile bottom sheet + const sheet = this.shadow.getElementById("mobile-bottom-sheet"); + const sheetHandle = this.shadow.getElementById("sheet-handle"); + if (sheet && sheetHandle) { + sheetHandle.addEventListener("click", () => { + sheet.classList.toggle("expanded"); + }); + // Touch drag on handle + let startY = 0; + let sheetWasExpanded = false; + sheetHandle.addEventListener("touchstart", (e: Event) => { + const te = e as TouchEvent; + startY = te.touches[0].clientY; + sheetWasExpanded = sheet.classList.contains("expanded"); + }, { passive: true }); + sheetHandle.addEventListener("touchend", (e: Event) => { + const te = e as TouchEvent; + const deltaY = te.changedTouches[0].clientY - startY; + if (sheetWasExpanded && deltaY > 40) { + sheet.classList.remove("expanded"); + } else if (!sheetWasExpanded && deltaY < -40) { + sheet.classList.add("expanded"); + } + }, { passive: true }); + } + // Ping buttons on history cards this.shadow.querySelectorAll("[data-ping-room]").forEach((btn) => { btn.addEventListener("click", (e) => { @@ -2274,6 +2564,23 @@ class FolkMapViewer extends HTMLElement { }); } + private closeMobileFab() { + const main = this.shadow.getElementById("fab-main"); + const list = this.shadow.getElementById("fab-mini-list"); + if (main) main.classList.remove("open"); + if (list) list.classList.remove("open"); + const popup = this.shadow.getElementById("mobile-privacy-popup"); + if (popup) popup.style.display = "none"; + } + + private updateMobileFab() { + const btn = this.shadow.getElementById("fab-share-loc"); + if (!btn) return; + btn.className = `fab-mini-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}`; + const label = btn.parentElement?.querySelector(".fab-mini-label"); + if (label) label.textContent = this.privacySettings.ghostMode ? "Ghost" : this.sharingLocation ? "Stop" : "Share"; + } + private goBack() { const prev = this._history.back(); if (!prev) return; diff --git a/modules/rmaps/components/map-privacy-panel.ts b/modules/rmaps/components/map-privacy-panel.ts index f4c1143..73670c7 100644 --- a/modules/rmaps/components/map-privacy-panel.ts +++ b/modules/rmaps/components/map-privacy-panel.ts @@ -1,16 +1,18 @@ /** - * — privacy settings dropdown for rMaps. - * Dispatches 'precision-change' and 'ghost-toggle' CustomEvents. + * — unified 5-level privacy control for rMaps. + * Dispatches 'precision-change' CustomEvent with the selected PrecisionLevel. + * "hidden" level replaces the old ghost mode toggle. */ import type { PrecisionLevel, PrivacySettings } from "./map-sync"; -const PRECISION_LABELS: Record = { - exact: "Exact", - building: "~50m (Building)", - area: "~500m (Area)", - approximate: "~5km (Approximate)", -}; +const LEVELS: { value: PrecisionLevel; label: string; icon: string; desc: string }[] = [ + { value: "exact", label: "Exact", icon: "\u{1F4CD}", desc: "Precise GPS location" }, + { value: "building", label: "~50m Building", icon: "\u{1F3E2}", desc: "Fuzzy to nearby building" }, + { value: "area", label: "~500m Area", icon: "\u{1F3D8}", desc: "Fuzzy to neighborhood" }, + { value: "approximate", label: "~5km Approx", icon: "\u{1F30D}", desc: "Fuzzy to city area" }, + { value: "hidden", label: "Hidden (Ghost)", icon: "\u{1F47B}", desc: "Stops sharing entirely" }, +]; class MapPrivacyPanel extends HTMLElement { private _settings: PrivacySettings = { precision: "exact", ghostMode: false }; @@ -18,41 +20,58 @@ class MapPrivacyPanel extends HTMLElement { static get observedAttributes() { return ["precision", "ghost"]; } get settings(): PrivacySettings { return this._settings; } - set settings(v: PrivacySettings) { this._settings = v; this.render(); } + set settings(v: PrivacySettings) { + this._settings = v; + // Sync ghost→hidden on ingest + if (v.ghostMode && v.precision !== "hidden") this._settings.precision = "hidden"; + this.render(); + } attributeChangedCallback() { this._settings.precision = (this.getAttribute("precision") as PrecisionLevel) || "exact"; this._settings.ghostMode = this.getAttribute("ghost") === "true"; + if (this._settings.ghostMode && this._settings.precision !== "hidden") this._settings.precision = "hidden"; this.render(); } connectedCallback() { this.render(); } private render() { + const active = this._settings.ghostMode ? "hidden" : this._settings.precision; + this.innerHTML = ` -
Privacy Settings
- - - -
- Ghost mode hides your location from all participants and stops GPS tracking. +
Privacy Level
+
+ ${LEVELS.map(l => { + const isActive = l.value === active; + const borderColor = isActive ? (l.value === "hidden" ? "#8b5cf6" : "#4f46e5") : "var(--rs-border)"; + const bg = isActive ? (l.value === "hidden" ? "#8b5cf620" : "#4f46e520") : "transparent"; + return ` + `; + }).join("")}
`; - this.querySelector("#precision-select")?.addEventListener("change", (e) => { - this._settings.precision = (e.target as HTMLSelectElement).value as PrecisionLevel; - this.dispatchEvent(new CustomEvent("precision-change", { detail: this._settings.precision, bubbles: true, composed: true })); - }); - this.querySelector("#ghost-toggle")?.addEventListener("change", () => { - this._settings.ghostMode = !this._settings.ghostMode; - this.dispatchEvent(new CustomEvent("ghost-toggle", { detail: this._settings.ghostMode, bubbles: true, composed: true })); + this.querySelectorAll(".priv-level-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const level = (btn as HTMLElement).dataset.level as PrecisionLevel; + this._settings.precision = level; + this._settings.ghostMode = level === "hidden"; + this.dispatchEvent(new CustomEvent("precision-change", { detail: level, bubbles: true, composed: true })); + this.render(); + }); }); } } diff --git a/modules/rmaps/components/map-privacy.ts b/modules/rmaps/components/map-privacy.ts index 49e9581..a76fab6 100644 --- a/modules/rmaps/components/map-privacy.ts +++ b/modules/rmaps/components/map-privacy.ts @@ -10,6 +10,7 @@ const PRECISION_RADIUS: Record = { building: 50, area: 500, approximate: 5000, + hidden: Infinity, }; /** diff --git a/modules/rmaps/components/map-share-modal.ts b/modules/rmaps/components/map-share-modal.ts index 5bfdbc9..d9eb7ec 100644 --- a/modules/rmaps/components/map-share-modal.ts +++ b/modules/rmaps/components/map-share-modal.ts @@ -26,7 +26,7 @@ class MapShareModal extends HTMLElement { this.innerHTML = `
-
Share Room
+
Share rMap
@@ -42,15 +42,11 @@ class MapShareModal extends HTMLElement {
`; - // QR code - try { - const QRCode = await import("qrcode"); - const dataUrl = await QRCode.toDataURL(this._url, { width: 200, margin: 2, color: { dark: "#000000", light: "#ffffff" } }); - const qr = this.querySelector("#s-qr"); - if (qr) qr.innerHTML = `QR Code`; - } catch { - const qr = this.querySelector("#s-qr"); - if (qr) qr.innerHTML = `
QR code unavailable
`; + // QR code via public API (no Node.js dependency) + const qr = this.querySelector("#s-qr"); + if (qr) { + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(this._url)}`; + qr.innerHTML = `QR Code`; } // Events diff --git a/modules/rmaps/components/map-sync.ts b/modules/rmaps/components/map-sync.ts index d669231..90f47c8 100644 --- a/modules/rmaps/components/map-sync.ts +++ b/modules/rmaps/components/map-sync.ts @@ -8,7 +8,7 @@ export type ParticipantStatus = "online" | "away" | "ghost" | "offline"; export type WaypointType = "meeting" | "poi" | "parking" | "food" | "danger" | "custom"; export type LocationSource = "gps" | "network" | "manual" | "ip" | "indoor"; -export type PrecisionLevel = "exact" | "building" | "area" | "approximate"; +export type PrecisionLevel = "exact" | "building" | "area" | "approximate" | "hidden"; export interface PrivacySettings { precision: PrecisionLevel; diff --git a/modules/rmaps/components/maps.css b/modules/rmaps/components/maps.css index 284104e..fe6acde 100644 --- a/modules/rmaps/components/maps.css +++ b/modules/rmaps/components/maps.css @@ -5,5 +5,5 @@ folk-map-viewer { padding: 16px; } @media (max-width: 768px) { - folk-map-viewer { padding: 8px; } + folk-map-viewer { padding: 0; } }