feat(rmaps): mobile UX overhaul — floating FAB menu, self-marker, bottom sheet, privacy consolidation

- Fix QR code (replace broken Node.js import with api.qrserver.com API)
- Rename "Share Room" → "Share rMap" across UI
- Add "hidden" precision level replacing ghost mode toggle
- Unified 5-level privacy panel (Exact → Hidden/Ghost) as button list
- Pulsing blue dot self-marker (replaces emoji circle for own position)
- Locate-me FAB (bottom-left, both mobile and desktop)
- Mobile: edge-to-edge map, floating FAB menu with staggered animations
- Mobile: bottom sheet for participants (peek/expand with touch drag)
- Mobile: hide sidebar/controls/privacy panel, overlay compact nav bar
- Extract shared participant list helpers for desktop sidebar + mobile sheet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 11:23:41 -07:00
parent 1c93e3bb67
commit db61e54d7b
6 changed files with 440 additions and 117 deletions

View File

@ -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,11 +1221,34 @@ 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";
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};
@ -1245,27 +1264,30 @@ class FolkMapViewer extends HTMLElement {
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
// Name label below (skip for self)
if (!isSelf) {
const label = document.createElement("div");
label.className = "marker-label";
label.style.cssText = `
@ -1276,11 +1298,16 @@ class FolkMapViewer extends HTMLElement {
`;
label.textContent = p.name;
el.appendChild(label);
}
el.addEventListener("click", () => {
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<string, ParticipantState>();
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<string, string> = { 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 ? `<button class="ping-btn-inline" data-ping="${p.id}" title="Ping" style="background:none;border:1px solid var(--rs-border);border-radius:4px;color:var(--rs-text-muted);cursor:pointer;padding:2px 6px;font-size:11px;">\u{1F514}</button>` : ""}
</div>`;
}).join("");
}
// Footer actions
list.insertAdjacentHTML("beforeend", `
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
<button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4CD} Meeting Point</button>
<button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E5} Import Places</button>
</div>
`);
// 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 = `
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
<button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4CD} Meeting Point</button>
<button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E5} Import Places</button>
</div>
`;
// 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,8 +1641,12 @@ class FolkMapViewer extends HTMLElement {
this.sync?.updateStatus("ghost");
this.sync?.clearLocation();
} else {
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)); }
}
</style>
@ -2193,13 +2361,58 @@ class FolkMapViewer extends HTMLElement {
${EMOJIS.map(e => `<button class="emoji-opt" data-emoji-pick="${e}" style="width:32px;height:32px;border-radius:6px;border:1px solid ${e === this.userEmoji ? "#4f46e5" : "transparent"};background:${e === this.userEmoji ? "#4f46e520" : "none"};font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;">${e}</button>`).join("")}
</div>
</div>
<button class="ctrl-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}" id="share-location">${this.privacySettings.ghostMode ? "\u{1F47B} Ghost Mode" : this.sharingLocation ? "\u{1F4CD} Stop Sharing" : "\u{1F4CD} Share Location"}</button>
<button class="ctrl-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.precision === "hidden" ? "ghost" : ""}" id="share-location">${this.privacySettings.precision === "hidden" ? "\u{1F47B} Hidden" : this.sharingLocation ? "\u{1F4CD} Stop Sharing" : "\u{1F4CD} Share Location"}</button>
<button class="ctrl-btn" id="privacy-toggle">\u{1F6E1} Privacy</button>
<button class="ctrl-btn" id="drop-waypoint">\u{1F4CC} Drop Pin</button>
<button class="ctrl-btn" id="share-room-btn">\u{1F4E4} Share Room</button>
<button class="ctrl-btn" id="share-room-btn">\u{1F4E4} Share rMap</button>
</div>
<div id="privacy-panel" style="display:${this.showPrivacyPanel ? "block" : "none"};background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:8px;padding:12px;margin-top:8px;"></div>
<!-- Locate-me FAB (visible on all viewports) -->
<button class="map-locate-fab" id="locate-me-fab" title="Center on my location">\u{1F3AF}</button>
<!-- Mobile FAB menu -->
<div class="mobile-fab-container" id="mobile-fab-container">
<div class="fab-mini-list" id="fab-mini-list">
<div class="fab-mini">
<button class="fab-mini-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}" id="fab-share-loc" title="Share Location">\u{1F4CD}</button>
<span class="fab-mini-label">${this.privacySettings.ghostMode ? "Ghost" : this.sharingLocation ? "Stop" : "Share"}</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-privacy" title="Privacy">\u{1F6E1}</button>
<span class="fab-mini-label">Privacy</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-drop-pin" title="Drop Pin">\u{1F4CC}</button>
<span class="fab-mini-label">Drop Pin</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-share-map" title="Share rMap">\u{1F4E4}</button>
<span class="fab-mini-label">Share rMap</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-emoji" title="Emoji">${this.userEmoji}</button>
<span class="fab-mini-label">Emoji</span>
</div>
</div>
<button class="fab-main" id="fab-main" title="Menu">\u2699</button>
</div>
<!-- Mobile bottom sheet (participants) -->
<div class="mobile-bottom-sheet" id="mobile-bottom-sheet">
<div class="sheet-handle" id="sheet-handle">
<div class="sheet-handle-bar"></div>
</div>
<div class="sheet-header">
<span>Participants</span>
<span style="font-size:10px;font-weight:400;color:var(--rs-text-muted);text-transform:none;letter-spacing:0;">tap to expand</span>
</div>
<div class="sheet-content" id="participant-list-mobile"></div>
</div>
<!-- Mobile privacy popup (hidden by default) -->
<div class="mobile-privacy-popup" id="mobile-privacy-popup" style="display:none;"></div>
`;
}
@ -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;

View File

@ -1,16 +1,18 @@
/**
* <map-privacy-panel> privacy settings dropdown for rMaps.
* Dispatches 'precision-change' and 'ghost-toggle' CustomEvents.
* <map-privacy-panel> 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<PrecisionLevel, string> = {
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 = `
<div style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);margin-bottom:8px;">Privacy Settings</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:6px;">Location Precision</label>
<select id="precision-select" style="width:100%;padding:6px 8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;margin-bottom:10px;">
${(["exact", "building", "area", "approximate"] as PrecisionLevel[]).map(p =>
`<option value="${p}" ${this._settings.precision === p ? "selected" : ""}>${PRECISION_LABELS[p]}</option>`
).join("")}
</select>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--rs-text-secondary);cursor:pointer;">
<input type="checkbox" id="ghost-toggle" ${this._settings.ghostMode ? "checked" : ""} style="accent-color:#8b5cf6;">
<span>\u{1F47B} Ghost Mode</span>
</label>
<div style="font-size:10px;color:var(--rs-text-muted);margin-top:4px;line-height:1.4;">
Ghost mode hides your location from all participants and stops GPS tracking.
<div style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);margin-bottom:8px;">Privacy Level</div>
<div style="display:flex;flex-direction:column;gap:4px;">
${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 `
<button class="priv-level-btn" data-level="${l.value}" style="
display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;
border:1px solid ${borderColor};background:${bg};
color:var(--rs-text-primary);cursor:pointer;text-align:left;font-size:12px;
transition:border-color 0.15s,background 0.15s;
">
<span style="font-size:16px;flex-shrink:0;">${l.icon}</span>
<div style="flex:1;min-width:0;">
<div style="font-weight:${isActive ? "600" : "500"};color:${isActive ? (l.value === "hidden" ? "#8b5cf6" : "#4f46e5") : "var(--rs-text-primary)"};">${l.label}</div>
<div style="font-size:10px;color:var(--rs-text-muted);margin-top:1px;">${l.desc}</div>
</div>
${isActive ? '<span style="color:#22c55e;font-size:14px;">&#10003;</span>' : ""}
</button>`;
}).join("")}
</div>
`;
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.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();
});
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 }));
});
}
}

View File

@ -10,6 +10,7 @@ const PRECISION_RADIUS: Record<PrecisionLevel, number> = {
building: 50,
area: 500,
approximate: 5000,
hidden: Infinity,
};
/**

View File

@ -26,7 +26,7 @@ class MapShareModal extends HTMLElement {
this.innerHTML = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:360px;width:90%;text-align:center;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);">Share Room</div>
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);">Share rMap</div>
<button id="s-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div id="s-qr" style="margin:16px auto;display:flex;align-items:center;justify-content:center;">
@ -42,15 +42,11 @@ class MapShareModal extends HTMLElement {
</div>
`;
// QR code
try {
const QRCode = await import("qrcode");
const dataUrl = await QRCode.toDataURL(this._url, { width: 200, margin: 2, color: { dark: "#000000", light: "#ffffff" } });
// QR code via public API (no Node.js dependency)
const qr = this.querySelector("#s-qr");
if (qr) qr.innerHTML = `<img src="${dataUrl}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;">`;
} catch {
const qr = this.querySelector("#s-qr");
if (qr) qr.innerHTML = `<div style="font-size:12px;color:var(--rs-text-muted);">QR code unavailable</div>`;
if (qr) {
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(this._url)}`;
qr.innerHTML = `<img src="${qrSrc}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;" onerror="this.parentElement.innerHTML='<div style=\\'font-size:12px;color:var(--rs-text-muted)\\'>QR code unavailable</div>'">`;
}
// Events

View File

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

View File

@ -5,5 +5,5 @@ folk-map-viewer {
padding: 16px;
}
@media (max-width: 768px) {
folk-map-viewer { padding: 8px; }
folk-map-viewer { padding: 0; }
}