|
|
|
|
@ -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<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,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)); }
|
|
|
|
|
}
|
|
|
|
|
</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;
|
|
|
|
|
|