diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 6b89935..c51e142 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -13,7 +13,7 @@ import { RoomSync, type RoomState, type ParticipantState, type LocationState, ty import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history"; import { MapPushManager } from "./map-push"; import { fuzzLocation, haversineDistance, formatDistance, formatTime } from "./map-privacy"; -import { parseGoogleMapsGeoJSON, type ParsedPlace } from "./map-import"; +import "./map-privacy-panel"; import { TourEngine } from "../../../shared/tour-engine"; import { ViewHistory } from "../../../shared/view-history.js"; import { requireAuth } from "../../../shared/auth-fetch"; @@ -107,10 +107,6 @@ class FolkMapViewer extends HTMLElement { private selectedParticipant: string | null = null; private selectedWaypoint: string | null = null; private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: string } | null = null; - private meetingSearchResults: { display_name: string; lat: string; lon: string }[] = []; - private meetingSearchQuery = ""; - private importParsedPlaces: { name: string; lat: number; lng: number; selected: boolean }[] = []; - private importStep: "upload" | "preview" | "done" = "upload"; private thumbnailTimer: ReturnType | null = null; private _themeObserver: MutationObserver | null = null; private _history = new ViewHistory<"lobby" | "map">("lobby"); @@ -1318,7 +1314,6 @@ class FolkMapViewer extends HTMLElement { }); list.querySelector("#sidebar-import-btn")?.addEventListener("click", () => { this.showImportModal = true; - this.importStep = "upload"; this.renderImportModal(); }); } @@ -1504,450 +1499,98 @@ class FolkMapViewer extends HTMLElement { private renderPrivacyPanel() { const panel = this.shadow.getElementById("privacy-panel"); if (!panel) return; - const precisionLabels: Record = { - exact: "Exact", building: "~50m (Building)", area: "~500m (Area)", approximate: "~5km (Approximate)", - }; - panel.innerHTML = ` -
Privacy Settings
- - - -
- Ghost mode hides your location from all participants and stops GPS tracking. -
- `; - panel.querySelector("#precision-select")?.addEventListener("change", (e) => { - this.privacySettings.precision = (e.target as HTMLSelectElement).value as PrecisionLevel; + // 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; }); - panel.querySelector("#ghost-toggle")?.addEventListener("change", () => { + privacyEl.addEventListener("ghost-toggle", () => { this.toggleGhostMode(); }); + panel.appendChild(privacyEl); } // ─── Waypoint drop / Meeting point modal ──────────────────── private dropWaypoint() { this.showMeetingModal = true; - this.meetingSearchQuery = ""; - this.meetingSearchResults = []; this.renderMeetingPointModal(); } private renderMeetingPointModal() { - let modal = this.shadow.getElementById("meeting-modal"); if (!this.showMeetingModal) { - modal?.remove(); + this.shadow.getElementById("meeting-modal")?.remove(); return; } - if (!modal) { - modal = document.createElement("div"); - modal.id = "meeting-modal"; - modal.style.cssText = ` - position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center; - background:rgba(0,0,0,0.6);backdrop-filter:blur(4px); - `; - this.shadow.appendChild(modal); - } - + // Lazy-load sub-component + import("./map-meeting-modal"); + const modal = document.createElement("map-meeting-modal") as any; + modal.id = "meeting-modal"; const center = this.map?.getCenter(); const myLoc = this.sync?.getState().participants[this.participantId]?.location; - const meetingEmojis = ["\u{1F4CD}", "\u{2B50}", "\u{1F3E0}", "\u{1F37D}", "\u{26FA}", "\u{1F3AF}", "\u{1F680}", "\u{1F33F}", "\u{26A1}", "\u{1F48E}"]; - - modal.innerHTML = ` -
-
-
\u{1F4CD} Set Meeting Point
- -
- - - - - -
- ${meetingEmojis.map((e, i) => ``).join("")} -
- - -
- - - -
- -
-
- ${center ? `Map center: ${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}` : "Select a location mode"} -
-
- - - - - - -
- `; - - // Listeners - modal.querySelector("#meeting-close")?.addEventListener("click", () => { - this.showMeetingModal = false; - modal?.remove(); - }); - modal.addEventListener("click", (e) => { - if (e.target === modal) { this.showMeetingModal = false; modal?.remove(); } - }); - - // Emoji picker - let selectedEmoji = "\u{1F4CD}"; - modal.querySelectorAll(".emoji-opt").forEach(btn => { - btn.addEventListener("click", () => { - selectedEmoji = (btn as HTMLElement).dataset.emoji!; - (modal!.querySelector("#meeting-emoji") as HTMLInputElement).value = selectedEmoji; - modal!.querySelectorAll(".emoji-opt").forEach(b => (b as HTMLElement).style.borderColor = "var(--rs-border)"); - (btn as HTMLElement).style.borderColor = "#4f46e5"; - }); - }); - - // GPS mode - modal.querySelector("#loc-gps")?.addEventListener("click", () => { - if (myLoc) { - (modal!.querySelector("#meeting-lat") as HTMLInputElement).value = String(myLoc.latitude); - (modal!.querySelector("#meeting-lng") as HTMLInputElement).value = String(myLoc.longitude); - modal!.querySelector("#loc-mode-content")!.innerHTML = `
\u2713 Using your current GPS: ${myLoc.latitude.toFixed(5)}, ${myLoc.longitude.toFixed(5)}
`; - } else { - modal!.querySelector("#loc-mode-content")!.innerHTML = `
Share your location first
`; - } - }); - - // Search mode - modal.querySelector("#loc-search")?.addEventListener("click", () => { - modal!.querySelector("#loc-mode-content")!.innerHTML = ` -
- - -
-
- `; - modal!.querySelector("#address-search-btn")?.addEventListener("click", async () => { - const q = (modal!.querySelector("#address-search") as HTMLInputElement).value.trim(); - if (!q) return; - const resultsDiv = modal!.querySelector("#search-results")!; - resultsDiv.innerHTML = '
Searching...
'; - try { - const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5`, { - headers: { "User-Agent": "rMaps/1.0" }, - signal: AbortSignal.timeout(5000), - }); - const data = await res.json(); - this.meetingSearchResults = data; - resultsDiv.innerHTML = data.length ? data.map((r: any, i: number) => ` -
- ${this.esc(r.display_name?.substring(0, 80))} -
- `).join("") : '
No results found
'; - resultsDiv.querySelectorAll("[data-sr]").forEach(el => { - el.addEventListener("click", () => { - const idx = parseInt((el as HTMLElement).dataset.sr!, 10); - const r = this.meetingSearchResults[idx]; - (modal!.querySelector("#meeting-lat") as HTMLInputElement).value = r.lat; - (modal!.querySelector("#meeting-lng") as HTMLInputElement).value = r.lon; - resultsDiv.querySelectorAll("[data-sr]").forEach(e => (e as HTMLElement).style.borderColor = "transparent"); - (el as HTMLElement).style.borderColor = "#4f46e5"; - }); - }); - } catch { - resultsDiv.innerHTML = '
Search failed
'; - } - }); - }); - - // Manual mode - modal.querySelector("#loc-manual")?.addEventListener("click", () => { - modal!.querySelector("#loc-mode-content")!.innerHTML = ` -
-
- - -
-
- - -
-
- `; - modal!.querySelector("#manual-lat")?.addEventListener("input", (e) => { - (modal!.querySelector("#meeting-lat") as HTMLInputElement).value = (e.target as HTMLInputElement).value; - }); - modal!.querySelector("#manual-lng")?.addEventListener("input", (e) => { - (modal!.querySelector("#meeting-lng") as HTMLInputElement).value = (e.target as HTMLInputElement).value; - }); - }); - - // Create - modal.querySelector("#meeting-create")?.addEventListener("click", () => { - const name = (modal!.querySelector("#meeting-name") as HTMLInputElement).value.trim() || "Meeting point"; - const lat = parseFloat((modal!.querySelector("#meeting-lat") as HTMLInputElement).value); - const lng = parseFloat((modal!.querySelector("#meeting-lng") as HTMLInputElement).value); - const emoji = (modal!.querySelector("#meeting-emoji") as HTMLInputElement).value || "\u{1F4CD}"; - - if (isNaN(lat) || isNaN(lng)) return; + if (center) modal.center = { lat: center.lat, lng: center.lng }; + if (myLoc) modal.myLocation = { lat: myLoc.latitude, lng: myLoc.longitude }; + modal.addEventListener("meeting-create", (e: CustomEvent) => { + const { name, lat, lng, emoji } = e.detail; this.sync?.addWaypoint({ - id: crypto.randomUUID(), - name, - emoji, - latitude: lat, - longitude: lng, + id: crypto.randomUUID(), name, emoji, + latitude: lat, longitude: lng, createdBy: this.participantId, createdAt: new Date().toISOString(), type: "meeting", }); this.showMeetingModal = false; - modal?.remove(); }); + modal.addEventListener("modal-close", () => { this.showMeetingModal = false; }); + + this.shadow.appendChild(modal); } // ─── Share modal with QR code ─────────────────────────────── private async renderShareModal() { - let modal = this.shadow.getElementById("share-modal"); if (!this.showShareModal) { - modal?.remove(); + this.shadow.getElementById("share-modal")?.remove(); return; } - if (!modal) { - modal = document.createElement("div"); - modal.id = "share-modal"; - modal.style.cssText = ` - position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center; - background:rgba(0,0,0,0.6);backdrop-filter:blur(4px); - `; - this.shadow.appendChild(modal); - } - - const shareUrl = `${window.location.origin}/${this.space}/rmaps/${this.room}`; - - modal.innerHTML = ` -
-
-
Share Room
- -
- -
-
Generating QR code...
-
- -
- ${this.esc(shareUrl)} -
- -
- - -
-
- `; - - // Generate QR code - try { - const QRCode = await import("qrcode"); - const dataUrl = await QRCode.toDataURL(shareUrl, { width: 200, margin: 2, color: { dark: "#000000", light: "#ffffff" } }); - const qrContainer = modal.querySelector("#qr-container"); - if (qrContainer) { - qrContainer.innerHTML = `QR Code`; - } - } catch { - const qrContainer = modal.querySelector("#qr-container"); - if (qrContainer) qrContainer.innerHTML = `
QR code unavailable
`; - } - - // Listeners - modal.querySelector("#share-close")?.addEventListener("click", () => { - this.showShareModal = false; - modal?.remove(); - }); - modal.addEventListener("click", (e) => { - if (e.target === modal) { this.showShareModal = false; modal?.remove(); } - }); - modal.querySelector("#share-copy")?.addEventListener("click", () => { - navigator.clipboard.writeText(shareUrl).then(() => { - const btn = modal!.querySelector("#share-copy"); - if (btn) { btn.textContent = "\u2713 Copied!"; setTimeout(() => { btn.textContent = "\u{1F4CB} Copy Link"; }, 2000); } - }); - }); - modal.querySelector("#share-native")?.addEventListener("click", () => { - if (navigator.share) { - navigator.share({ title: `rMaps: ${this.room}`, url: shareUrl }).catch(() => {}); - } else { - navigator.clipboard.writeText(shareUrl).then(() => { - const btn = modal!.querySelector("#share-native"); - if (btn) { btn.textContent = "\u2713 Copied!"; setTimeout(() => { btn.textContent = "\u{1F4E4} Share"; }, 2000); } - }); - } - }); + import("./map-share-modal"); + const modal = document.createElement("map-share-modal") as any; + modal.id = "share-modal"; + modal.url = `${window.location.origin}/${this.space}/rmaps/${this.room}`; + modal.room = this.room; + modal.addEventListener("modal-close", () => { this.showShareModal = false; }); + this.shadow.appendChild(modal); } // ─── Import modal ─────────────────────────────────────────── private renderImportModal() { - let modal = this.shadow.getElementById("import-modal"); if (!this.showImportModal) { - modal?.remove(); + this.shadow.getElementById("import-modal")?.remove(); return; } - if (!modal) { - modal = document.createElement("div"); - modal.id = "import-modal"; - modal.style.cssText = ` - position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center; - background:rgba(0,0,0,0.6);backdrop-filter:blur(4px); - `; - this.shadow.appendChild(modal); - } - - if (this.importStep === "upload") { - modal.innerHTML = ` -
-
-
\u{1F4E5} Import Places
- -
- -
-
\u{1F4C2}
-
Drop a GeoJSON file here
-
or click to browse (.json, .geojson)
- -
- - -
- `; - - const handleFile = (file: File) => { - if (file.size > 50 * 1024 * 1024) { - const errDiv = modal!.querySelector("#import-error") as HTMLElement; - errDiv.style.display = "block"; - errDiv.textContent = "File too large (max 50 MB)"; - return; - } - const reader = new FileReader(); - reader.onload = () => { - const result = parseGoogleMapsGeoJSON(reader.result as string); - if (!result.success) { - const errDiv = modal!.querySelector("#import-error") as HTMLElement; - errDiv.style.display = "block"; - errDiv.textContent = result.error || "No places found"; - return; - } - this.importParsedPlaces = result.places.map(p => ({ ...p, selected: true })); - this.importStep = "preview"; - this.renderImportModal(); - }; - reader.readAsText(file); - }; - - const dropZone = modal.querySelector("#drop-zone")!; - const fileInput = modal.querySelector("#file-input") as HTMLInputElement; - - dropZone.addEventListener("click", () => fileInput.click()); - dropZone.addEventListener("dragover", (e) => { e.preventDefault(); (dropZone as HTMLElement).style.borderColor = "#4f46e5"; }); - dropZone.addEventListener("dragleave", () => { (dropZone as HTMLElement).style.borderColor = "var(--rs-border)"; }); - dropZone.addEventListener("drop", (e) => { - e.preventDefault(); - (dropZone as HTMLElement).style.borderColor = "var(--rs-border)"; - const file = (e as DragEvent).dataTransfer?.files[0]; - if (file) handleFile(file); - }); - fileInput.addEventListener("change", () => { - if (fileInput.files?.[0]) handleFile(fileInput.files[0]); - }); - } else if (this.importStep === "preview") { - const selectedCount = this.importParsedPlaces.filter(p => p.selected).length; - modal.innerHTML = ` -
-
-
Preview (${this.importParsedPlaces.length} places)
- -
- -
- ${this.importParsedPlaces.map((p, i) => ` - - `).join("")} -
- - -
- `; - - modal.querySelectorAll("[data-place-idx]").forEach(cb => { - cb.addEventListener("change", (e) => { - const idx = parseInt((cb as HTMLElement).dataset.placeIdx!, 10); - this.importParsedPlaces[idx].selected = (e.target as HTMLInputElement).checked; - const btn = modal!.querySelector("#import-confirm"); - const count = this.importParsedPlaces.filter(p => p.selected).length; - if (btn) btn.textContent = `Import ${count} Places as Waypoints`; + import("./map-import-modal"); + const modal = document.createElement("map-import-modal") as any; + modal.id = "import-modal"; + modal.addEventListener("import-places", (e: CustomEvent) => { + for (const p of e.detail.places) { + this.sync?.addWaypoint({ + id: crypto.randomUUID(), + name: p.name, + emoji: "\u{1F4CD}", + latitude: p.lat, + longitude: p.lng, + createdBy: this.participantId, + createdAt: new Date().toISOString(), + type: "poi", }); - }); - - modal.querySelector("#import-confirm")?.addEventListener("click", () => { - for (const p of this.importParsedPlaces) { - if (!p.selected) continue; - this.sync?.addWaypoint({ - id: crypto.randomUUID(), - name: p.name, - emoji: "\u{1F4CD}", - latitude: p.lat, - longitude: p.lng, - createdBy: this.participantId, - createdAt: new Date().toISOString(), - type: "poi", - }); - } - this.importStep = "done"; - this.renderImportModal(); - }); - } else if (this.importStep === "done") { - const count = this.importParsedPlaces.filter(p => p.selected).length; - modal.innerHTML = ` -
-
\u2705
-
Imported ${count} places!
-
They've been added as waypoints to this room.
- -
- `; - modal.querySelector("#import-done-btn")?.addEventListener("click", () => { - this.showImportModal = false; - modal?.remove(); - }); - } - - // Close handlers (shared) - modal.querySelector("#import-close")?.addEventListener("click", () => { - this.showImportModal = false; - modal?.remove(); - }); - modal.addEventListener("click", (e) => { - if (e.target === modal) { this.showImportModal = false; modal?.remove(); } + } }); + modal.addEventListener("modal-close", () => { this.showImportModal = false; }); + this.shadow.appendChild(modal); } // ─── Route display ────────────────────────────────────────── diff --git a/modules/rmaps/components/map-import-modal.ts b/modules/rmaps/components/map-import-modal.ts new file mode 100644 index 0000000..3150fa2 --- /dev/null +++ b/modules/rmaps/components/map-import-modal.ts @@ -0,0 +1,137 @@ +/** + * — Google Maps GeoJSON import modal for rMaps. + * Dispatches 'import-places' CustomEvent with { places: { name, lat, lng }[] } detail. + * Dispatches 'modal-close' on dismiss. + */ + +import { parseGoogleMapsGeoJSON } from "./map-import"; + +class MapImportModal extends HTMLElement { + private _step: "upload" | "preview" | "done" = "upload"; + private _places: { name: string; lat: number; lng: number; selected: boolean }[] = []; + + connectedCallback() { this.render(); } + + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } + + private close() { + this.dispatchEvent(new CustomEvent("modal-close", { bubbles: true, composed: true })); + this.remove(); + } + + private render() { + this.style.cssText = `position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);`; + + if (this._step === "upload") this.renderUpload(); + else if (this._step === "preview") this.renderPreview(); + else this.renderDone(); + + // Shared close + this.querySelector("#i-close")?.addEventListener("click", () => this.close()); + this.addEventListener("click", (e) => { if (e.target === this) this.close(); }); + } + + private renderUpload() { + this.innerHTML = ` +
+
+
\u{1F4E5} Import Places
+ +
+
+
\u{1F4C2}
+
Drop a GeoJSON file here
+
or click to browse (.json, .geojson)
+ +
+ +
+ `; + + const handleFile = (file: File) => { + if (file.size > 50 * 1024 * 1024) { + const err = this.querySelector("#i-err") as HTMLElement; + err.style.display = "block"; err.textContent = "File too large (max 50 MB)"; return; + } + const reader = new FileReader(); + reader.onload = () => { + const result = parseGoogleMapsGeoJSON(reader.result as string); + if (!result.success) { + const err = this.querySelector("#i-err") as HTMLElement; + err.style.display = "block"; err.textContent = result.error || "No places found"; return; + } + this._places = result.places.map(p => ({ ...p, selected: true })); + this._step = "preview"; + this.render(); + }; + reader.readAsText(file); + }; + + const drop = this.querySelector("#i-drop")!; + const fileInput = this.querySelector("#i-file") as HTMLInputElement; + drop.addEventListener("click", () => fileInput.click()); + drop.addEventListener("dragover", (e) => { e.preventDefault(); (drop as HTMLElement).style.borderColor = "#4f46e5"; }); + drop.addEventListener("dragleave", () => { (drop as HTMLElement).style.borderColor = "var(--rs-border)"; }); + drop.addEventListener("drop", (e) => { + e.preventDefault(); (drop as HTMLElement).style.borderColor = "var(--rs-border)"; + const file = (e as DragEvent).dataTransfer?.files[0]; + if (file) handleFile(file); + }); + fileInput.addEventListener("change", () => { if (fileInput.files?.[0]) handleFile(fileInput.files[0]); }); + } + + private renderPreview() { + const count = this._places.filter(p => p.selected).length; + this.innerHTML = ` +
+
+
Preview (${this._places.length} places)
+ +
+
+ ${this._places.map((p, i) => ` + + `).join("")} +
+ +
+ `; + + this.querySelectorAll("[data-idx]").forEach(cb => { + cb.addEventListener("change", (e) => { + this._places[parseInt((cb as HTMLElement).dataset.idx!, 10)].selected = (e.target as HTMLInputElement).checked; + const btn = this.querySelector("#i-confirm"); + if (btn) btn.textContent = `Import ${this._places.filter(p => p.selected).length} Places as Waypoints`; + }); + }); + + this.querySelector("#i-confirm")?.addEventListener("click", () => { + const selected = this._places.filter(p => p.selected).map(({ name, lat, lng }) => ({ name, lat, lng })); + this.dispatchEvent(new CustomEvent("import-places", { detail: { places: selected }, bubbles: true, composed: true })); + this._step = "done"; + this.render(); + }); + } + + private renderDone() { + const count = this._places.filter(p => p.selected).length; + this.innerHTML = ` +
+
\u2705
+
Imported ${count} places!
+
They've been added as waypoints to this room.
+ +
+ `; + this.querySelector("#i-done")?.addEventListener("click", () => this.close()); + } +} + +customElements.define("map-import-modal", MapImportModal); +export { MapImportModal }; diff --git a/modules/rmaps/components/map-meeting-modal.ts b/modules/rmaps/components/map-meeting-modal.ts new file mode 100644 index 0000000..e9b797e --- /dev/null +++ b/modules/rmaps/components/map-meeting-modal.ts @@ -0,0 +1,163 @@ +/** + * — meeting point creation modal for rMaps. + * Dispatches 'meeting-create' CustomEvent with { name, lat, lng, emoji } detail. + * Dispatches 'modal-close' on dismiss. + */ + +const MODAL_STYLE = `position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);`; +const MEETING_EMOJIS = ["\u{1F4CD}", "\u{2B50}", "\u{1F3E0}", "\u{1F37D}", "\u{26FA}", "\u{1F3AF}", "\u{1F680}", "\u{1F33F}", "\u{26A1}", "\u{1F48E}"]; + +class MapMeetingModal extends HTMLElement { + private _centerLat = 0; + private _centerLng = 0; + private _myLat: number | null = null; + private _myLng: number | null = null; + private _selectedEmoji = "\u{1F4CD}"; + private _searchResults: { display_name: string; lat: string; lon: string }[] = []; + + set center(v: { lat: number; lng: number }) { this._centerLat = v.lat; this._centerLng = v.lng; } + set myLocation(v: { lat: number; lng: number } | null) { + if (v) { this._myLat = v.lat; this._myLng = v.lng; } else { this._myLat = null; this._myLng = null; } + } + + connectedCallback() { this.render(); } + + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } + + private close() { + this.dispatchEvent(new CustomEvent("modal-close", { bubbles: true, composed: true })); + this.remove(); + } + + private render() { + this.style.cssText = MODAL_STYLE; + + this.innerHTML = ` +
+
+
\u{1F4CD} Set Meeting Point
+ +
+ + + + + +
+ ${MEETING_EMOJIS.map((e, i) => ``).join("")} +
+ + +
+ + + +
+ +
+
+ Map center: ${this._centerLat.toFixed(5)}, ${this._centerLng.toFixed(5)} +
+
+ + + + + +
+ `; + + // Close + this.querySelector("#m-close")?.addEventListener("click", () => this.close()); + this.addEventListener("click", (e) => { if (e.target === this) this.close(); }); + + // Emoji picker + this.querySelectorAll(".m-emoji").forEach(btn => { + btn.addEventListener("click", () => { + this._selectedEmoji = (btn as HTMLElement).dataset.emoji!; + this.querySelectorAll(".m-emoji").forEach(b => (b as HTMLElement).style.borderColor = "var(--rs-border)"); + (btn as HTMLElement).style.borderColor = "#4f46e5"; + }); + }); + + // GPS + this.querySelector("#m-gps")?.addEventListener("click", () => { + const content = this.querySelector("#m-loc-content")!; + if (this._myLat !== null && this._myLng !== null) { + (this.querySelector("#m-lat") as HTMLInputElement).value = String(this._myLat); + (this.querySelector("#m-lng") as HTMLInputElement).value = String(this._myLng); + content.innerHTML = `
\u2713 GPS: ${this._myLat!.toFixed(5)}, ${this._myLng!.toFixed(5)}
`; + } else { + content.innerHTML = `
Share your location first
`; + } + }); + + // Search + this.querySelector("#m-search")?.addEventListener("click", () => { + const content = this.querySelector("#m-loc-content")!; + content.innerHTML = ` +
+ + +
+
+ `; + this.querySelector("#m-addr-go")?.addEventListener("click", async () => { + const q = (this.querySelector("#m-addr") as HTMLInputElement).value.trim(); + if (!q) return; + const sr = this.querySelector("#m-sr")!; + sr.innerHTML = '
Searching...
'; + try { + const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5`, { + headers: { "User-Agent": "rMaps/1.0" }, signal: AbortSignal.timeout(5000), + }); + this._searchResults = await res.json(); + sr.innerHTML = this._searchResults.length ? this._searchResults.map((r, i) => ` +
${this.esc(r.display_name?.substring(0, 80))}
+ `).join("") : '
No results
'; + sr.querySelectorAll("[data-sr]").forEach(el => { + el.addEventListener("click", () => { + const r = this._searchResults[parseInt((el as HTMLElement).dataset.sr!, 10)]; + (this.querySelector("#m-lat") as HTMLInputElement).value = r.lat; + (this.querySelector("#m-lng") as HTMLInputElement).value = r.lon; + sr.querySelectorAll("[data-sr]").forEach(e => (e as HTMLElement).style.borderColor = "transparent"); + (el as HTMLElement).style.borderColor = "#4f46e5"; + }); + }); + } catch { sr.innerHTML = '
Search failed
'; } + }); + }); + + // Manual + this.querySelector("#m-manual")?.addEventListener("click", () => { + const content = this.querySelector("#m-loc-content")!; + content.innerHTML = ` +
+
+
+
+
+
+ `; + this.querySelector("#m-mlat")?.addEventListener("input", (e) => { (this.querySelector("#m-lat") as HTMLInputElement).value = (e.target as HTMLInputElement).value; }); + this.querySelector("#m-mlng")?.addEventListener("input", (e) => { (this.querySelector("#m-lng") as HTMLInputElement).value = (e.target as HTMLInputElement).value; }); + }); + + // Create + this.querySelector("#m-create")?.addEventListener("click", () => { + const name = (this.querySelector("#m-name") as HTMLInputElement).value.trim() || "Meeting point"; + const lat = parseFloat((this.querySelector("#m-lat") as HTMLInputElement).value); + const lng = parseFloat((this.querySelector("#m-lng") as HTMLInputElement).value); + if (isNaN(lat) || isNaN(lng)) return; + this.dispatchEvent(new CustomEvent("meeting-create", { + detail: { name, lat, lng, emoji: this._selectedEmoji }, + bubbles: true, composed: true, + })); + this.close(); + }); + } +} + +customElements.define("map-meeting-modal", MapMeetingModal); +export { MapMeetingModal }; diff --git a/modules/rmaps/components/map-privacy-panel.ts b/modules/rmaps/components/map-privacy-panel.ts new file mode 100644 index 0000000..f4c1143 --- /dev/null +++ b/modules/rmaps/components/map-privacy-panel.ts @@ -0,0 +1,61 @@ +/** + * — privacy settings dropdown for rMaps. + * Dispatches 'precision-change' and 'ghost-toggle' CustomEvents. + */ + +import type { PrecisionLevel, PrivacySettings } from "./map-sync"; + +const PRECISION_LABELS: Record = { + exact: "Exact", + building: "~50m (Building)", + area: "~500m (Area)", + approximate: "~5km (Approximate)", +}; + +class MapPrivacyPanel extends HTMLElement { + private _settings: PrivacySettings = { precision: "exact", ghostMode: false }; + + static get observedAttributes() { return ["precision", "ghost"]; } + + get settings(): PrivacySettings { return this._settings; } + set settings(v: PrivacySettings) { this._settings = v; this.render(); } + + attributeChangedCallback() { + this._settings.precision = (this.getAttribute("precision") as PrecisionLevel) || "exact"; + this._settings.ghostMode = this.getAttribute("ghost") === "true"; + this.render(); + } + + connectedCallback() { this.render(); } + + private render() { + this.innerHTML = ` +
Privacy Settings
+ + + +
+ Ghost mode hides your location from all participants and stops GPS tracking. +
+ `; + + 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 })); + }); + } +} + +customElements.define("map-privacy-panel", MapPrivacyPanel); +export { MapPrivacyPanel }; diff --git a/modules/rmaps/components/map-share-modal.ts b/modules/rmaps/components/map-share-modal.ts new file mode 100644 index 0000000..56758cb --- /dev/null +++ b/modules/rmaps/components/map-share-modal.ts @@ -0,0 +1,79 @@ +/** + * — QR code share modal for rMaps rooms. + * Dispatches 'modal-close' on dismiss. + * Set `url` property before appending to DOM. + */ + +class MapShareModal extends HTMLElement { + private _url = ""; + private _room = ""; + + set url(v: string) { this._url = v; } + set room(v: string) { this._room = v; } + + connectedCallback() { this.render(); } + + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } + + private close() { + this.dispatchEvent(new CustomEvent("modal-close", { bubbles: true, composed: true })); + this.remove(); + } + + private async render() { + this.style.cssText = `position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);`; + + this.innerHTML = ` +
+
+
Share Room
+ +
+
+
Generating QR code...
+
+
+ ${this.esc(this._url)} +
+
+ + +
+
+ `; + + // 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
`; + } + + // Events + this.querySelector("#s-close")?.addEventListener("click", () => this.close()); + this.addEventListener("click", (e) => { if (e.target === this) this.close(); }); + this.querySelector("#s-copy")?.addEventListener("click", () => { + navigator.clipboard.writeText(this._url).then(() => { + const btn = this.querySelector("#s-copy"); + if (btn) { btn.textContent = "\u2713 Copied!"; setTimeout(() => { btn.textContent = "\u{1F4CB} Copy Link"; }, 2000); } + }); + }); + this.querySelector("#s-share")?.addEventListener("click", () => { + if (navigator.share) { + navigator.share({ title: `rMaps: ${this._room}`, url: this._url }).catch(() => {}); + } else { + navigator.clipboard.writeText(this._url).then(() => { + const btn = this.querySelector("#s-share"); + if (btn) { btn.textContent = "\u2713 Copied!"; setTimeout(() => { btn.textContent = "\u{1F4E4} Share"; }, 2000); } + }); + } + }); + } +} + +customElements.define("map-share-modal", MapShareModal); +export { MapShareModal }; diff --git a/website/sw.ts b/website/sw.ts index 6a65918..ce44907 100644 --- a/website/sw.ts +++ b/website/sw.ts @@ -6,6 +6,8 @@ const STATIC_CACHE = `${CACHE_VERSION}-static`; const HTML_CACHE = `${CACHE_VERSION}-html`; const API_CACHE = `${CACHE_VERSION}-api`; const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`; +const TILE_CACHE = `${CACHE_VERSION}-tiles`; +const TILE_CACHE_MAX = 500; // Vite-hashed assets are immutable (content hash in filename) const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; @@ -204,6 +206,34 @@ self.addEventListener("fetch", (event) => { return; } + // OSM map tiles: cache-first with LRU eviction + if (url.hostname === "tile.openstreetmap.org" && url.pathname.endsWith(".png")) { + event.respondWith( + caches.open(TILE_CACHE).then(async (cache) => { + const cached = await cache.match(event.request); + if (cached) return cached; + + const response = await fetch(event.request); + if (response.ok) { + const clone = response.clone(); + // Put tile in cache, then trim if needed + cache.put(event.request, clone).then(async () => { + const keys = await cache.keys(); + if (keys.length > TILE_CACHE_MAX) { + // Evict oldest entries (FIFO — approximation of LRU) + const toDelete = keys.length - TILE_CACHE_MAX; + for (let i = 0; i < toDelete; i++) { + await cache.delete(keys[i]); + } + } + }).catch(() => {}); + } + return response; + }).catch(() => new Response("Tile unavailable", { status: 503 })) + ); + return; + } + // Other assets (images, fonts, etc.): stale-while-revalidate event.respondWith( caches.match(event.request).then((cached) => { @@ -259,6 +289,43 @@ self.addEventListener("message", (event) => { if (msg.type === "clear-ecosystem-cache") { event.waitUntil(caches.delete(ECOSYSTEM_CACHE)); } + + // rMaps: save room state to IndexedDB for offline persistence + if (msg.type === "SAVE_ROOM_STATE" && msg.roomSlug && msg.state) { + event.waitUntil( + openRmapsDB().then((db) => { + const tx = db.transaction("rooms", "readwrite"); + tx.objectStore("rooms").put({ slug: msg.roomSlug, state: msg.state, savedAt: Date.now() }); + return new Promise((resolve) => { tx.oncomplete = () => resolve(); }); + }).catch(() => {}) + ); + } + + // rMaps: get room state from IndexedDB + if (msg.type === "GET_ROOM_STATE" && msg.roomSlug) { + event.waitUntil( + openRmapsDB().then(async (db) => { + return new Promise((resolve) => { + const tx = db.transaction("rooms", "readonly"); + const req = tx.objectStore("rooms").get(msg.roomSlug); + req.onsuccess = () => { + event.source?.postMessage({ + type: "ROOM_STATE_RESULT", + roomSlug: msg.roomSlug, + state: req.result?.state || null, + }); + resolve(); + }; + req.onerror = () => { + event.source?.postMessage({ type: "ROOM_STATE_RESULT", roomSlug: msg.roomSlug, state: null }); + resolve(); + }; + }); + }).catch(() => { + event.source?.postMessage({ type: "ROOM_STATE_RESULT", roomSlug: msg.roomSlug, state: null }); + }) + ); + } }); // ============================================================================ @@ -268,13 +335,69 @@ self.addEventListener("message", (event) => { self.addEventListener("push", (event) => { if (!event.data) return; - let payload: { title: string; body?: string; icon?: string; badge?: string; tag?: string; data?: any }; + let payload: { title: string; body?: string; icon?: string; badge?: string; tag?: string; data?: any; type?: string }; try { payload = event.data.json(); } catch { payload = { title: event.data.text() || "rSpace" }; } + // rMaps location request push — notify all clients to share location + if (payload.type === "location_request") { + event.waitUntil( + (async () => { + // Show notification + await self.registration.showNotification(payload.title || "Location Requested", { + body: payload.body || "Someone is asking for your location", + icon: "/icons/icon-192.png", + badge: "/icons/icon-192.png", + tag: "rmaps-location-request", + data: { ...payload.data, type: "location_request" }, + }); + + // Notify open clients to auto-share if enabled + const clients = await self.clients.matchAll({ type: "window" }); + if (clients.length > 0) { + for (const client of clients) { + client.postMessage({ type: "LOCATION_REQUEST", data: payload.data }); + } + } else { + // No open clients — try to return last-known location from IndexedDB + const roomSlug = payload.data?.roomSlug; + if (roomSlug) { + try { + const db = await openRmapsDB(); + const tx = db.transaction("rooms", "readonly"); + const req = tx.objectStore("rooms").get(roomSlug); + await new Promise((resolve) => { + req.onsuccess = async () => { + const savedState = req.result?.state; + if (savedState) { + // Post last-known state back to sync server so the pinger sees it + try { + await fetch("/rmaps/api/push/last-known-location", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomSlug, + state: savedState, + savedAt: req.result.savedAt, + }), + }); + } catch { /* best effort */ } + } + resolve(); + }; + req.onerror = () => resolve(); + }); + } catch { /* IndexedDB unavailable */ } + } + } + })() + ); + return; + } + event.waitUntil( self.registration.showNotification(payload.title, { body: payload.body, @@ -304,6 +427,27 @@ self.addEventListener("notificationclick", (event) => { ); }); +// ============================================================================ +// RMAPS OFFLINE DB +// ============================================================================ + +const RMAPS_DB_NAME = "rmaps-offline"; +const RMAPS_DB_VERSION = 1; + +function openRmapsDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(RMAPS_DB_NAME, RMAPS_DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("rooms")) { + db.createObjectStore("rooms", { keyPath: "slug" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + /** Minimal offline fallback page when nothing is cached. */ function offlineFallbackPage(): Response { const html = `