269 lines
8.3 KiB
TypeScript
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);
|