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:
parent
7c363dbae9
commit
4d72ba164e
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
146
website/sw.ts
146
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<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue