import { GeoApiClient } from "../libs/geo-api-client/dist/index.js"; class BrowserStorage { getItem(key) { return localStorage.getItem(key); } setItem(key, value) { localStorage.setItem(key, value); } removeItem(key) { localStorage.removeItem(key); } } const statusEl = document.getElementById("status"); const apiBaseEl = document.getElementById("apiBase"); const publicKeyPreviewEl = document.getElementById("publicKeyPreview"); const collectionInfoEl = document.getElementById("collectionInfo"); const collectionNameEl = document.getElementById("collectionName"); const assetFileEl = document.getElementById("assetFile"); const assetNameEl = document.getElementById("assetName"); const assetDescEl = document.getElementById("assetDesc"); const assetsListEl = document.getElementById("assetsList"); let client = new GeoApiClient(apiBaseEl.value.trim(), new BrowserStorage()); let keys = null; let accessToken = ""; let collectionId = ""; let selectedLatLng = null; const markers = new Map(); const map = L.map("map").setView([28.4636, -16.2518], 10); L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap", }).addTo(map); let pendingMarker = null; map.on("click", (event) => { selectedLatLng = event.latlng; if (pendingMarker) map.removeLayer(pendingMarker); pendingMarker = L.marker(event.latlng, { title: "Pending feature position" }).addTo(map); setStatus(`Selected location: ${event.latlng.lat.toFixed(5)}, ${event.latlng.lng.toFixed(5)}`); }); function setStatus(message) { statusEl.textContent = message; } function extFromFilename(name) { const idx = name.lastIndexOf("."); if (idx <= 0) return ""; return name.slice(idx + 1).toLowerCase(); } function kindFromExt(ext) { return ext === "gltf" || ext === "glb" ? "3d" : "image"; } function isLikelyInternalHostname(hostname) { if (!hostname) return false; if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true; if (hostname.endsWith(".local") || hostname.endsWith(".internal")) return true; return hostname.includes("minio") || hostname.includes("docker") || hostname.includes("kubernetes"); } async function sha256Hex(file) { const buffer = await file.arrayBuffer(); const digest = await crypto.subtle.digest("SHA-256", buffer); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } function setClientBase(baseUrl) { const normalized = baseUrl.trim().replace(/\/+$/g, ""); client = new GeoApiClient(normalized, new BrowserStorage()); if (accessToken) client.setAccessToken(accessToken); localStorage.setItem("geo_api_base", normalized); setStatus(`API base updated: ${normalized}`); } async function ensureKeys() { keys = await client.ensureKeysInStorage(); publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`; } async function register() { if (!keys) await ensureKeys(); await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey); } async function login() { if (!keys) await ensureKeys(); accessToken = await client.loginWithSignature(keys.publicKey, keys.privateKey); client.setAccessToken(accessToken); } async function ensureCollection() { if (collectionId) return collectionId; const created = await client.createCollection(collectionNameEl.value.trim() || "3D objects demo"); collectionId = created.id; collectionInfoEl.textContent = `${created.name} (${created.id})`; return collectionId; } function renderAssets(features) { assetsListEl.innerHTML = ""; for (const feature of features) { const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : []; for (const asset of assets) { const card = document.createElement("div"); card.className = "asset-card"; const absoluteLink = client.resolveRelativeLink(asset.link); card.innerHTML = `
${asset.kind} • ${asset.ext}
Feature: ${feature.id}
Visibility: ${asset.isPublic ? "public" : "private"}
Link: ${asset.link}
`; const actions = document.createElement("div"); actions.className = "asset-actions"; const openBtn = document.createElement("button"); openBtn.textContent = "Open"; openBtn.onclick = () => window.open(absoluteLink, "_blank", "noopener,noreferrer"); actions.appendChild(openBtn); const toggleBtn = document.createElement("button"); toggleBtn.textContent = asset.isPublic ? "Set Private" : "Set Public"; toggleBtn.onclick = async () => { try { await client.setAssetVisibility(asset.id, !asset.isPublic); await refreshFeatures(); setStatus(`Updated visibility for ${asset.id}`); } catch (error) { setStatus(error.message); } }; actions.appendChild(toggleBtn); const copyBtn = document.createElement("button"); copyBtn.textContent = "Copy Share Link"; copyBtn.onclick = async () => { await navigator.clipboard.writeText(absoluteLink); setStatus("Share link copied to clipboard."); }; actions.appendChild(copyBtn); card.appendChild(actions); assetsListEl.appendChild(card); } } if (!assetsListEl.children.length) { assetsListEl.innerHTML = `
No assets linked yet.
`; } } async function refreshFeatures() { if (!collectionId) return; const { features } = await client.listFeatures(collectionId); for (const feature of features) { const coords = feature.geometry?.coordinates; if (!coords || coords.length < 2) continue; const lat = coords[1]; const lon = coords[0]; if (!markers.has(feature.id)) { const marker = L.marker([lat, lon]).addTo(map); marker.bindPopup(`Feature ${feature.id}`); markers.set(feature.id, marker); } } renderAssets(features); } async function createFeatureAndUpload() { if (!selectedLatLng) { throw new Error("Click the map to choose object location first."); } const file = assetFileEl.files?.[0]; if (!file) { throw new Error("Select a 3D/image file first."); } const ext = extFromFilename(file.name); if (!ext) { throw new Error("File extension is required."); } await ensureCollection(); const featureName = assetNameEl.value.trim() || file.name; const feature = await client.createPointFeature( collectionId, selectedLatLng.lng, selectedLatLng.lat, { name: featureName, placement: "leaflet-demo" } ); const checksum = await sha256Hex(file); const kind = kindFromExt(ext); const created = await client.createOrLinkAsset({ featureId: feature.id, checksum, ext, kind, mimeType: file.type || "application/octet-stream", sizeBytes: file.size, name: featureName, description: assetDescEl.value.trim(), isPublic: true, }); const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream"); let signedHost = ""; try { signedHost = new URL(signedUpload.url).hostname; } catch { signedHost = ""; } if (signedHost && isLikelyInternalHostname(signedHost) && signedHost !== window.location.hostname) { throw new Error( `Upload URL host "${signedHost}" is not browser-reachable from this page. ` + `Configure S3 endpoint/signing host to a public domain (for example s3.tenerife.baby) or proxy uploads through the API.` ); } let uploadRes; try { uploadRes = await fetch(signedUpload.url, { method: signedUpload.method || "PUT", headers: file.type ? { "Content-Type": file.type } : undefined, body: file, }); } catch (error) { throw new Error( `Network error while uploading to signed URL. ` + `Check that object storage endpoint is publicly reachable and CORS allows browser PUT requests.` ); } if (!uploadRes.ok) { throw new Error(`Upload failed with status ${uploadRes.status}`); } await refreshFeatures(); assetNameEl.value = ""; assetDescEl.value = ""; assetFileEl.value = ""; setStatus("3D/image object stored and linked. Share link available in Stored Assets."); } document.getElementById("applyApi").onclick = () => { setClientBase(apiBaseEl.value); }; document.getElementById("ensureKeys").onclick = async () => { try { await ensureKeys(); setStatus("Keys are ready."); } catch (error) { setStatus(error.message); } }; document.getElementById("register").onclick = async () => { try { await register(); setStatus("Registered."); } catch (error) { setStatus(error.message); } }; document.getElementById("login").onclick = async () => { try { await login(); setStatus("Logged in."); } catch (error) { setStatus(error.message); } }; document.getElementById("createCollection").onclick = async () => { try { await ensureCollection(); setStatus("Collection is ready."); } catch (error) { setStatus(error.message); } }; document.getElementById("uploadAsset").onclick = async () => { try { if (!accessToken) throw new Error("Login first."); await createFeatureAndUpload(); } catch (error) { setStatus(error.message); } }; const savedBase = localStorage.getItem("geo_api_base"); if (savedBase) { apiBaseEl.value = savedBase; setClientBase(savedBase); }