Merge branch 'dev'
This commit is contained in:
commit
fb249a8853
|
|
@ -13,7 +13,7 @@ import { RoomSync, type RoomState, type ParticipantState, type LocationState, ty
|
||||||
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
|
import { loadRoomHistory, saveRoomVisit, updateRoomThumbnail } from "./map-room-history";
|
||||||
import { MapPushManager } from "./map-push";
|
import { MapPushManager } from "./map-push";
|
||||||
import { fuzzLocation, haversineDistance, formatDistance, formatTime } from "./map-privacy";
|
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 { TourEngine } from "../../../shared/tour-engine";
|
||||||
import { ViewHistory } from "../../../shared/view-history.js";
|
import { ViewHistory } from "../../../shared/view-history.js";
|
||||||
import { requireAuth } from "../../../shared/auth-fetch";
|
import { requireAuth } from "../../../shared/auth-fetch";
|
||||||
|
|
@ -107,10 +107,6 @@ class FolkMapViewer extends HTMLElement {
|
||||||
private selectedParticipant: string | null = null;
|
private selectedParticipant: string | null = null;
|
||||||
private selectedWaypoint: string | null = null;
|
private selectedWaypoint: string | null = null;
|
||||||
private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: 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 thumbnailTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private _themeObserver: MutationObserver | null = null;
|
private _themeObserver: MutationObserver | null = null;
|
||||||
private _history = new ViewHistory<"lobby" | "map">("lobby");
|
private _history = new ViewHistory<"lobby" | "map">("lobby");
|
||||||
|
|
@ -1318,7 +1314,6 @@ class FolkMapViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
list.querySelector("#sidebar-import-btn")?.addEventListener("click", () => {
|
list.querySelector("#sidebar-import-btn")?.addEventListener("click", () => {
|
||||||
this.showImportModal = true;
|
this.showImportModal = true;
|
||||||
this.importStep = "upload";
|
|
||||||
this.renderImportModal();
|
this.renderImportModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1504,412 +1499,84 @@ class FolkMapViewer extends HTMLElement {
|
||||||
private renderPrivacyPanel() {
|
private renderPrivacyPanel() {
|
||||||
const panel = this.shadow.getElementById("privacy-panel");
|
const panel = this.shadow.getElementById("privacy-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const precisionLabels: Record<PrecisionLevel, string> = {
|
// Use sub-component
|
||||||
exact: "Exact", building: "~50m (Building)", area: "~500m (Area)", approximate: "~5km (Approximate)",
|
panel.innerHTML = "";
|
||||||
};
|
const privacyEl = document.createElement("map-privacy-panel") as any;
|
||||||
panel.innerHTML = `
|
privacyEl.settings = this.privacySettings;
|
||||||
<div style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);margin-bottom:8px;">Privacy Settings</div>
|
privacyEl.addEventListener("precision-change", (e: CustomEvent) => {
|
||||||
<label style="font-size:12px;color:var(--rs-text-secondary);display:block;margin-bottom:6px;">Location Precision</label>
|
this.privacySettings.precision = e.detail;
|
||||||
<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;
|
|
||||||
});
|
});
|
||||||
panel.querySelector("#ghost-toggle")?.addEventListener("change", () => {
|
privacyEl.addEventListener("ghost-toggle", () => {
|
||||||
this.toggleGhostMode();
|
this.toggleGhostMode();
|
||||||
});
|
});
|
||||||
|
panel.appendChild(privacyEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Waypoint drop / Meeting point modal ────────────────────
|
// ─── Waypoint drop / Meeting point modal ────────────────────
|
||||||
|
|
||||||
private dropWaypoint() {
|
private dropWaypoint() {
|
||||||
this.showMeetingModal = true;
|
this.showMeetingModal = true;
|
||||||
this.meetingSearchQuery = "";
|
|
||||||
this.meetingSearchResults = [];
|
|
||||||
this.renderMeetingPointModal();
|
this.renderMeetingPointModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderMeetingPointModal() {
|
private renderMeetingPointModal() {
|
||||||
let modal = this.shadow.getElementById("meeting-modal");
|
|
||||||
if (!this.showMeetingModal) {
|
if (!this.showMeetingModal) {
|
||||||
modal?.remove();
|
this.shadow.getElementById("meeting-modal")?.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!modal) {
|
// Lazy-load sub-component
|
||||||
modal = document.createElement("div");
|
import("./map-meeting-modal");
|
||||||
|
const modal = document.createElement("map-meeting-modal") as any;
|
||||||
modal.id = "meeting-modal";
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const center = this.map?.getCenter();
|
const center = this.map?.getCenter();
|
||||||
const myLoc = this.sync?.getState().participants[this.participantId]?.location;
|
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}"];
|
if (center) modal.center = { lat: center.lat, lng: center.lng };
|
||||||
|
if (myLoc) modal.myLocation = { lat: myLoc.latitude, lng: myLoc.longitude };
|
||||||
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;
|
|
||||||
|
|
||||||
|
modal.addEventListener("meeting-create", (e: CustomEvent) => {
|
||||||
|
const { name, lat, lng, emoji } = e.detail;
|
||||||
this.sync?.addWaypoint({
|
this.sync?.addWaypoint({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(), name, emoji,
|
||||||
name,
|
latitude: lat, longitude: lng,
|
||||||
emoji,
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lng,
|
|
||||||
createdBy: this.participantId,
|
createdBy: this.participantId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
type: "meeting",
|
type: "meeting",
|
||||||
});
|
});
|
||||||
this.showMeetingModal = false;
|
this.showMeetingModal = false;
|
||||||
modal?.remove();
|
|
||||||
});
|
});
|
||||||
|
modal.addEventListener("modal-close", () => { this.showMeetingModal = false; });
|
||||||
|
|
||||||
|
this.shadow.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Share modal with QR code ───────────────────────────────
|
// ─── Share modal with QR code ───────────────────────────────
|
||||||
|
|
||||||
private async renderShareModal() {
|
private async renderShareModal() {
|
||||||
let modal = this.shadow.getElementById("share-modal");
|
|
||||||
if (!this.showShareModal) {
|
if (!this.showShareModal) {
|
||||||
modal?.remove();
|
this.shadow.getElementById("share-modal")?.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!modal) {
|
import("./map-share-modal");
|
||||||
modal = document.createElement("div");
|
const modal = document.createElement("map-share-modal") as any;
|
||||||
modal.id = "share-modal";
|
modal.id = "share-modal";
|
||||||
modal.style.cssText = `
|
modal.url = `${window.location.origin}/${this.space}/rmaps/${this.room}`;
|
||||||
position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;
|
modal.room = this.room;
|
||||||
background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);
|
modal.addEventListener("modal-close", () => { this.showShareModal = false; });
|
||||||
`;
|
|
||||||
this.shadow.appendChild(modal);
|
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 modal ───────────────────────────────────────────
|
// ─── Import modal ───────────────────────────────────────────
|
||||||
|
|
||||||
private renderImportModal() {
|
private renderImportModal() {
|
||||||
let modal = this.shadow.getElementById("import-modal");
|
|
||||||
if (!this.showImportModal) {
|
if (!this.showImportModal) {
|
||||||
modal?.remove();
|
this.shadow.getElementById("import-modal")?.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!modal) {
|
import("./map-import-modal");
|
||||||
modal = document.createElement("div");
|
const modal = document.createElement("map-import-modal") as any;
|
||||||
modal.id = "import-modal";
|
modal.id = "import-modal";
|
||||||
modal.style.cssText = `
|
modal.addEventListener("import-places", (e: CustomEvent) => {
|
||||||
position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;
|
for (const p of e.detail.places) {
|
||||||
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`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.querySelector("#import-confirm")?.addEventListener("click", () => {
|
|
||||||
for (const p of this.importParsedPlaces) {
|
|
||||||
if (!p.selected) continue;
|
|
||||||
this.sync?.addWaypoint({
|
this.sync?.addWaypoint({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
|
@ -1921,33 +1588,9 @@ class FolkMapViewer extends HTMLElement {
|
||||||
type: "poi",
|
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 ──────────────────────────────────────────
|
// ─── 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 HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||||
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
|
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)
|
// Vite-hashed assets are immutable (content hash in filename)
|
||||||
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
||||||
|
|
@ -204,6 +206,34 @@ self.addEventListener("fetch", (event) => {
|
||||||
return;
|
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
|
// Other assets (images, fonts, etc.): stale-while-revalidate
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
caches.match(event.request).then((cached) => {
|
||||||
|
|
@ -259,6 +289,43 @@ self.addEventListener("message", (event) => {
|
||||||
if (msg.type === "clear-ecosystem-cache") {
|
if (msg.type === "clear-ecosystem-cache") {
|
||||||
event.waitUntil(caches.delete(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) => {
|
self.addEventListener("push", (event) => {
|
||||||
if (!event.data) return;
|
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 {
|
try {
|
||||||
payload = event.data.json();
|
payload = event.data.json();
|
||||||
} catch {
|
} catch {
|
||||||
payload = { title: event.data.text() || "rSpace" };
|
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(
|
event.waitUntil(
|
||||||
self.registration.showNotification(payload.title, {
|
self.registration.showNotification(payload.title, {
|
||||||
body: payload.body,
|
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. */
|
/** Minimal offline fallback page when nothing is cached. */
|
||||||
function offlineFallbackPage(): Response {
|
function offlineFallbackPage(): Response {
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue