138 lines
7.1 KiB
TypeScript
138 lines
7.1 KiB
TypeScript
/**
|
|
* <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 };
|