feat(rmaps): decompose modals into sub-components + SW offline pinging

Extract meeting point, share, import, and privacy modals from
folk-map-viewer.ts (2504→2147 lines) into standalone web components
that communicate via CustomEvent dispatch. Add OSM tile caching
(cache-first, LRU at 500), IndexedDB room state persistence for
offline location pinging, and auto-persist room state on every sync
update so last-known positions survive tab close.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 21:18:16 -07:00
parent 7c363dbae9
commit 4d72ba164e
6 changed files with 634 additions and 407 deletions

View File

@ -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<typeof setTimeout> | 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<PrecisionLevel, string> = {
exact: "Exact", building: "~50m (Building)", area: "~500m (Area)", approximate: "~5km (Approximate)",
};
panel.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.privacySettings.precision === p ? "selected" : ""}>${precisionLabels[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.privacySettings.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>
`;
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 = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:20px;max-width:400px;width:90%;max-height:80vh;overflow-y:auto;">
<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);">\u{1F4CD} Set Meeting Point</div>
<button id="meeting-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:4px;">Name</label>
<input type="text" id="meeting-name" placeholder="Meeting point" value="Meeting point"
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:13px;margin-bottom:12px;">
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:6px;">Emoji</label>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;" id="emoji-picker">
${meetingEmojis.map((e, i) => `<button class="emoji-opt" data-emoji="${e}" style="width:32px;height:32px;border-radius:6px;border:1px solid ${i === 0 ? "#4f46e5" : "var(--rs-border)"};background:var(--rs-input-bg);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;">${e}</button>`).join("")}
</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:4px;">Location</label>
<div style="display:flex;gap:6px;margin-bottom:10px;">
<button id="loc-gps" class="loc-mode-btn" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F4CD} Current GPS</button>
<button id="loc-search" class="loc-mode-btn" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F50E} Search Address</button>
<button id="loc-manual" class="loc-mode-btn" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F4DD} Manual</button>
</div>
<div id="loc-mode-content">
<div style="font-size:12px;color:var(--rs-text-muted);text-align:center;padding:12px;">
${center ? `Map center: ${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}` : "Select a location mode"}
</div>
</div>
<input type="hidden" id="meeting-lat" value="${center?.lat || myLoc?.latitude || 0}">
<input type="hidden" id="meeting-lng" value="${center?.lng || myLoc?.longitude || 0}">
<input type="hidden" id="meeting-emoji" value="\u{1F4CD}">
<button id="meeting-create" style="width:100%;margin-top:14px;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Drop Meeting Point</button>
</div>
`;
// 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 = `<div style="font-size:12px;color:#22c55e;text-align:center;padding:12px;">\u2713 Using your current GPS: ${myLoc.latitude.toFixed(5)}, ${myLoc.longitude.toFixed(5)}</div>`;
} else {
modal!.querySelector("#loc-mode-content")!.innerHTML = `<div style="font-size:12px;color:#f59e0b;text-align:center;padding:12px;">Share your location first</div>`;
}
});
// Search mode
modal.querySelector("#loc-search")?.addEventListener("click", () => {
modal!.querySelector("#loc-mode-content")!.innerHTML = `
<div style="display:flex;gap:6px;margin-bottom:8px;">
<input type="text" id="address-search" placeholder="Search address..." style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;">
<button id="address-search-btn" style="padding:8px 12px;border-radius:6px;border:none;background:#4f46e5;color:#fff;cursor:pointer;font-size:12px;">Search</button>
</div>
<div id="search-results"></div>
`;
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 = '<div style="font-size:11px;color:var(--rs-text-muted);padding:6px;">Searching...</div>';
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) => `
<div class="search-result" data-sr="${i}" style="padding:6px 8px;border-radius:6px;cursor:pointer;font-size:12px;color:var(--rs-text-secondary);border:1px solid transparent;margin-bottom:4px;">
${this.esc(r.display_name?.substring(0, 80))}
</div>
`).join("") : '<div style="font-size:11px;color:var(--rs-text-muted);padding:6px;">No results found</div>';
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 = '<div style="font-size:11px;color:#ef4444;padding:6px;">Search failed</div>';
}
});
});
// Manual mode
modal.querySelector("#loc-manual")?.addEventListener("click", () => {
modal!.querySelector("#loc-mode-content")!.innerHTML = `
<div style="display:flex;gap:8px;">
<div style="flex:1;">
<label style="font-size:11px;color:var(--rs-text-muted);">Latitude</label>
<input type="number" step="any" id="manual-lat" value="${center?.lat || 0}" style="width:100%;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;">
</div>
<div style="flex:1;">
<label style="font-size:11px;color:var(--rs-text-muted);">Longitude</label>
<input type="number" step="any" id="manual-lng" value="${center?.lng || 0}" style="width:100%;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;">
</div>
</div>
`;
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 = `
<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>
<button id="share-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div id="qr-container" style="margin:16px auto;display:flex;align-items:center;justify-content:center;">
<div style="font-size:12px;color:var(--rs-text-muted);">Generating QR code...</div>
</div>
<div style="background:var(--rs-bg-surface-sunken);border:1px solid var(--rs-border);border-radius:8px;padding:10px;margin:12px 0;font-family:monospace;font-size:11px;color:var(--rs-text-secondary);word-break:break-all;text-align:left;">
${this.esc(shareUrl)}
</div>
<div style="display:flex;gap:8px;">
<button id="share-copy" style="flex:1;padding:10px;border-radius:8px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:13px;font-weight:500;">\u{1F4CB} Copy Link</button>
<button id="share-native" style="flex:1;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">\u{1F4E4} Share</button>
</div>
</div>
`;
// 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 = `<img src="${dataUrl}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;">`;
}
} catch {
const qrContainer = modal.querySelector("#qr-container");
if (qrContainer) qrContainer.innerHTML = `<div style="font-size:12px;color:var(--rs-text-muted);">QR code unavailable</div>`;
}
// 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 = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;">
<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);">\u{1F4E5} Import Places</div>
<button id="import-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div id="drop-zone" style="border:2px dashed var(--rs-border);border-radius:10px;padding:40px;text-align:center;cursor:pointer;transition:border-color 0.2s;">
<div style="font-size:28px;margin-bottom:8px;">\u{1F4C2}</div>
<div style="font-size:13px;color:var(--rs-text-secondary);margin-bottom:4px;">Drop a GeoJSON file here</div>
<div style="font-size:11px;color:var(--rs-text-muted);">or click to browse (.json, .geojson)</div>
<input type="file" id="file-input" accept=".json,.geojson" style="display:none;">
</div>
<div id="import-error" style="display:none;margin-top:12px;font-size:12px;color:#ef4444;padding:8px;border-radius:6px;background:rgba(239,68,68,0.1);"></div>
</div>
`;
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 = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;max-height:80vh;overflow-y:auto;">
<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);">Preview (${this.importParsedPlaces.length} places)</div>
<button id="import-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div style="margin-bottom:12px;">
${this.importParsedPlaces.map((p, i) => `
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);cursor:pointer;font-size:12px;color:var(--rs-text-secondary);">
<input type="checkbox" data-place-idx="${i}" ${p.selected ? "checked" : ""} style="accent-color:#4f46e5;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;color:var(--rs-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:10px;color:var(--rs-text-muted);">${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}</div>
</div>
</label>
`).join("")}
</div>
<button id="import-confirm" style="width:100%;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Import ${selectedCount} Places as Waypoints</button>
</div>
`;
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 = `
<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="font-size:32px;margin-bottom:8px;">\u2705</div>
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);margin-bottom:8px;">Imported ${count} places!</div>
<div style="font-size:12px;color:var(--rs-text-muted);margin-bottom:16px;">They've been added as waypoints to this room.</div>
<button id="import-done-btn" style="padding:10px 24px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Done</button>
</div>
`;
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 ──────────────────────────────────────────

View File

@ -0,0 +1,137 @@
/**
* <map-import-modal> 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 = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;">
<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);">\u{1F4E5} Import Places</div>
<button id="i-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div id="i-drop" style="border:2px dashed var(--rs-border);border-radius:10px;padding:40px;text-align:center;cursor:pointer;transition:border-color 0.2s;">
<div style="font-size:28px;margin-bottom:8px;">\u{1F4C2}</div>
<div style="font-size:13px;color:var(--rs-text-secondary);margin-bottom:4px;">Drop a GeoJSON file here</div>
<div style="font-size:11px;color:var(--rs-text-muted);">or click to browse (.json, .geojson)</div>
<input type="file" id="i-file" accept=".json,.geojson" style="display:none;">
</div>
<div id="i-err" style="display:none;margin-top:12px;font-size:12px;color:#ef4444;padding:8px;border-radius:6px;background:rgba(239,68,68,0.1);"></div>
</div>
`;
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 = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;max-height:80vh;overflow-y:auto;">
<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);">Preview (${this._places.length} places)</div>
<button id="i-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<div style="margin-bottom:12px;">
${this._places.map((p, i) => `
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);cursor:pointer;font-size:12px;color:var(--rs-text-secondary);">
<input type="checkbox" data-idx="${i}" ${p.selected ? "checked" : ""} style="accent-color:#4f46e5;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;color:var(--rs-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:10px;color:var(--rs-text-muted);">${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}</div>
</div>
</label>
`).join("")}
</div>
<button id="i-confirm" style="width:100%;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Import ${count} Places as Waypoints</button>
</div>
`;
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 = `
<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="font-size:32px;margin-bottom:8px;">\u2705</div>
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);margin-bottom:8px;">Imported ${count} places!</div>
<div style="font-size:12px;color:var(--rs-text-muted);margin-bottom:16px;">They've been added as waypoints to this room.</div>
<button id="i-done" style="padding:10px 24px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Done</button>
</div>
`;
this.querySelector("#i-done")?.addEventListener("click", () => this.close());
}
}
customElements.define("map-import-modal", MapImportModal);
export { MapImportModal };

View File

@ -0,0 +1,163 @@
/**
* <map-meeting-modal> 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 = `
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:20px;max-width:400px;width:90%;max-height:80vh;overflow-y:auto;">
<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);">\u{1F4CD} Set Meeting Point</div>
<button id="m-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:4px;">Name</label>
<input type="text" id="m-name" placeholder="Meeting point" value="Meeting point"
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:13px;margin-bottom:12px;">
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:6px;">Emoji</label>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;" id="m-emoji-picker">
${MEETING_EMOJIS.map((e, i) => `<button class="m-emoji" data-emoji="${e}" style="width:32px;height:32px;border-radius:6px;border:1px solid ${i === 0 ? "#4f46e5" : "var(--rs-border)"};background:var(--rs-input-bg);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;">${e}</button>`).join("")}
</div>
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:4px;">Location</label>
<div style="display:flex;gap:6px;margin-bottom:10px;">
<button id="m-gps" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F4CD} Current GPS</button>
<button id="m-search" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F50E} Search</button>
<button id="m-manual" style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">\u{1F4DD} Manual</button>
</div>
<div id="m-loc-content">
<div style="font-size:12px;color:var(--rs-text-muted);text-align:center;padding:12px;">
Map center: ${this._centerLat.toFixed(5)}, ${this._centerLng.toFixed(5)}
</div>
</div>
<input type="hidden" id="m-lat" value="${this._centerLat}">
<input type="hidden" id="m-lng" value="${this._centerLng}">
<button id="m-create" style="width:100%;margin-top:14px;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;">Drop Meeting Point</button>
</div>
`;
// 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 = `<div style="font-size:12px;color:#22c55e;text-align:center;padding:12px;">\u2713 GPS: ${this._myLat!.toFixed(5)}, ${this._myLng!.toFixed(5)}</div>`;
} else {
content.innerHTML = `<div style="font-size:12px;color:#f59e0b;text-align:center;padding:12px;">Share your location first</div>`;
}
});
// Search
this.querySelector("#m-search")?.addEventListener("click", () => {
const content = this.querySelector("#m-loc-content")!;
content.innerHTML = `
<div style="display:flex;gap:6px;margin-bottom:8px;">
<input type="text" id="m-addr" placeholder="Search address..." style="flex:1;padding:8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;">
<button id="m-addr-go" style="padding:8px 12px;border-radius:6px;border:none;background:#4f46e5;color:#fff;cursor:pointer;font-size:12px;">Search</button>
</div>
<div id="m-sr"></div>
`;
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 = '<div style="font-size:11px;color:var(--rs-text-muted);padding:6px;">Searching...</div>';
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) => `
<div data-sr="${i}" style="padding:6px 8px;border-radius:6px;cursor:pointer;font-size:12px;color:var(--rs-text-secondary);border:1px solid transparent;margin-bottom:4px;">${this.esc(r.display_name?.substring(0, 80))}</div>
`).join("") : '<div style="font-size:11px;color:var(--rs-text-muted);padding:6px;">No results</div>';
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 = '<div style="font-size:11px;color:#ef4444;padding:6px;">Search failed</div>'; }
});
});
// Manual
this.querySelector("#m-manual")?.addEventListener("click", () => {
const content = this.querySelector("#m-loc-content")!;
content.innerHTML = `
<div style="display:flex;gap:8px;">
<div style="flex:1;"><label style="font-size:11px;color:var(--rs-text-muted);">Latitude</label>
<input type="number" step="any" id="m-mlat" value="${this._centerLat}" style="width:100%;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;"></div>
<div style="flex:1;"><label style="font-size:11px;color:var(--rs-text-muted);">Longitude</label>
<input type="number" step="any" id="m-mlng" value="${this._centerLng}" style="width:100%;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:12px;"></div>
</div>
`;
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 };

View File

@ -0,0 +1,61 @@
/**
* <map-privacy-panel> privacy settings dropdown for rMaps.
* Dispatches 'precision-change' and 'ghost-toggle' CustomEvents.
*/
import type { PrecisionLevel, PrivacySettings } from "./map-sync";
const PRECISION_LABELS: Record<PrecisionLevel, string> = {
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 = `
<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>
`;
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 };

View File

@ -0,0 +1,79 @@
/**
* <map-share-modal> 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 = `
<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>
<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;">
<div style="font-size:12px;color:var(--rs-text-muted);">Generating QR code...</div>
</div>
<div style="background:var(--rs-bg-surface-sunken);border:1px solid var(--rs-border);border-radius:8px;padding:10px;margin:12px 0;font-family:monospace;font-size:11px;color:var(--rs-text-secondary);word-break:break-all;text-align:left;">
${this.esc(this._url)}
</div>
<div style="display:flex;gap:8px;">
<button id="s-copy" style="flex:1;padding:10px;border-radius:8px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:13px;font-weight:500;">\u{1F4CB} Copy Link</button>
<button id="s-share" style="flex:1;padding:10px;border-radius:8px;border:none;background:#4f46e5;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">\u{1F4E4} Share</button>
</div>
</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" } });
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>`;
}
// 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 };

View File

@ -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<void>((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<void>((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<void>((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<IDBDatabase> {
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 = `<!DOCTYPE html>