rspace-online/modules/maps/components/folk-map-viewer.ts

269 lines
8.3 KiB
TypeScript

/**
* <folk-map-viewer> — real-time location sharing map.
*
* Creates/joins map rooms, shows participant locations on a map,
* and provides location sharing controls.
*/
class FolkMapViewer extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private room = "";
private view: "lobby" | "map" = "lobby";
private rooms: string[] = [];
private loading = false;
private error = "";
private syncStatus: "disconnected" | "connected" = "disconnected";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.room = this.getAttribute("room") || "";
if (this.room) {
this.view = "map";
}
this.checkSyncHealth();
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/maps/);
return match ? `/${match[1]}/maps` : "";
}
private async checkSyncHealth() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
this.syncStatus = data.sync !== false ? "connected" : "disconnected";
}
} catch {
this.syncStatus = "disconnected";
}
this.render();
}
private async loadStats() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/stats`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = await res.json();
this.rooms = Object.keys(data.rooms || {});
}
} catch {
this.rooms = [];
}
this.render();
}
private joinRoom(slug: string) {
this.room = slug;
this.view = "map";
this.render();
}
private createRoom() {
const name = prompt("Room name (slug):");
if (!name?.trim()) return;
const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
this.joinRoom(slug);
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.header { display: flex; gap: 8px; margin-bottom: 20px; align-items: center; }
.nav-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid #444;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px;
}
.nav-btn:hover { border-color: #666; }
.header-title { font-size: 18px; font-weight: 600; margin-left: 8px; flex: 1; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
}
.status-connected { background: #22c55e; }
.status-disconnected { background: #ef4444; }
.create-btn {
padding: 10px 20px; border-radius: 8px; border: none;
background: #6366f1; color: #fff; font-weight: 600; cursor: pointer; font-size: 14px;
}
.create-btn:hover { background: #4f46e5; }
.room-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.2s;
display: flex; align-items: center; gap: 12px;
}
.room-card:hover { border-color: #555; }
.room-icon { font-size: 24px; }
.room-name { font-size: 15px; font-weight: 600; }
.map-container {
width: 100%; height: 500px; border-radius: 10px;
background: #1a1a2e; border: 1px solid #333;
display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden;
}
.map-placeholder {
text-align: center; color: #666; padding: 40px;
}
.map-placeholder p { margin: 8px 0; }
.controls {
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
}
.ctrl-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid #444;
background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px;
}
.ctrl-btn:hover { border-color: #666; }
.ctrl-btn.sharing {
border-color: #22c55e; color: #22c55e; animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.share-link {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 12px; margin-top: 12px; font-family: monospace; font-size: 12px;
color: #aaa; display: flex; align-items: center; gap: 8px;
}
.share-link span { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.copy-btn {
padding: 4px 10px; border-radius: 4px; border: 1px solid #444;
background: #2a2a3e; color: #ccc; cursor: pointer; font-size: 11px;
}
.empty { text-align: center; color: #666; padding: 40px; }
</style>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:12px">${this.esc(this.error)}</div>` : ""}
${this.view === "lobby" ? this.renderLobby() : this.renderMap()}
`;
this.attachListeners();
}
private renderLobby(): string {
return `
<div class="header">
<span class="header-title">Map Rooms</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
<span style="font-size:12px;color:#888;margin-right:12px">${this.syncStatus === "connected" ? "Sync online" : "Sync offline"}</span>
<button class="create-btn" id="create-room">+ New Room</button>
</div>
${this.rooms.length > 0 ? this.rooms.map((r) => `
<div class="room-card" data-room="${r}">
<span class="room-icon">\u{1F5FA}</span>
<span class="room-name">${this.esc(r)}</span>
</div>
`).join("") : ""}
<div class="empty">
<p style="font-size:16px;margin-bottom:8px">Create or join a map room to share locations</p>
<p style="font-size:13px">Share the room link with friends to see each other on the map in real-time</p>
</div>
`;
}
private renderMap(): string {
const shareUrl = `${window.location.origin}/${this.space}/maps/${this.room}`;
return `
<div class="header">
<button class="nav-btn" data-back="lobby">Back</button>
<span class="header-title">\u{1F5FA} ${this.esc(this.room)}</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
</div>
<div class="map-container">
<div class="map-placeholder">
<p style="font-size:48px">\u{1F30D}</p>
<p style="font-size:16px">Map Room: <strong>${this.esc(this.room)}</strong></p>
<p>Connect the MapLibre GL library to display the interactive map.</p>
<p style="font-size:12px;color:#555">WebSocket sync: ${this.syncStatus}</p>
</div>
</div>
<div class="controls">
<button class="ctrl-btn" id="share-location">Share My Location</button>
<button class="ctrl-btn" id="copy-link">Copy Room Link</button>
</div>
<div class="share-link">
<span>${this.esc(shareUrl)}</span>
<button class="copy-btn" id="copy-url">Copy</button>
</div>
`;
}
private attachListeners() {
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
el.addEventListener("click", () => {
const room = (el as HTMLElement).dataset.room!;
this.joinRoom(room);
});
});
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => {
this.view = "lobby";
this.loadStats();
});
});
this.shadow.getElementById("share-location")?.addEventListener("click", () => {
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const btn = this.shadow.getElementById("share-location");
if (btn) {
btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
btn.classList.add("sharing");
}
},
() => {
this.error = "Location access denied";
this.render();
}
);
}
});
const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link");
copyUrl?.addEventListener("click", () => {
const url = `${window.location.origin}/${this.space}/maps/${this.room}`;
navigator.clipboard.writeText(url).then(() => {
if (copyUrl) copyUrl.textContent = "Copied!";
setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000);
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-map-viewer", FolkMapViewer);