diff --git a/README.md b/README.md
index 44ba045..cb26251 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,7 @@ Then visit:
- Production: `https://tenerife.baby/web/`
- Local: `http://localhost:8122/web/`
- Local Leaflet demo: `http://localhost:8122/web/leaflet-demo.html`
+- Local MapLibre GL + Three.js demo: `http://localhost:8122/web/maplibre-demo.html`
## Documentation
diff --git a/docs/frontend-development.md b/docs/frontend-development.md
index 9ba9f3f..568e603 100644
--- a/docs/frontend-development.md
+++ b/docs/frontend-development.md
@@ -32,6 +32,7 @@ web/
2. Open:
- `http://localhost:8122/web/`
- `http://localhost:8122/web/leaflet-demo.html` (Leaflet map demo for 3D/image placement + sharing)
+ - `http://localhost:8122/web/maplibre-demo.html` (MapLibre GL vector tiles + Three.js object rendering/placement)
### Runtime dependencies
@@ -60,6 +61,11 @@ web/
- click map to place object coordinates
- create feature + upload/link `gltf`/`glb`/image asset
- copy/open share link and toggle public/private visibility
+- MapLibre GL + Three.js example:
+ - vector tile basemap via MapLibre style
+ - map click to place object position
+ - 3D marker rendering in custom Three.js layer
+ - asset upload/link and share/visibility controls backed by API
## TypeScript client (`libs/geo-api-client`)
diff --git a/internal/app/service.go b/internal/app/service.go
index 84ec6e4..c7ea14a 100644
--- a/internal/app/service.go
+++ b/internal/app/service.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"strings"
"time"
@@ -36,8 +37,8 @@ type Config struct {
}
type AssetURLSigner interface {
- SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, contentType string) (string, error)
SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error)
+ PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) error
}
type Service struct {
@@ -513,11 +514,24 @@ func (s *Service) SignedUploadURL(ownerKey, assetID, contentType string) (string
if asset.OwnerKey != ownerKey {
return "", ErrForbidden
}
- url, err := s.assetSigner.SignedPutObjectURL(context.Background(), asset.ObjectKey, s.config.UploadURLTTL, contentType)
- if err != nil {
- return "", err
+ return "/v1/assets/" + asset.ID + "/upload", nil
+}
+
+func (s *Service) UploadAsset(ownerKey, assetID, contentType string, body io.Reader, size int64) error {
+ if s.assetSigner == nil {
+ return ErrStorageNotConfigured
}
- return url, nil
+ asset, err := s.store.GetAsset(assetID)
+ if err != nil {
+ return ErrAssetMiss
+ }
+ if asset.OwnerKey != ownerKey {
+ return ErrForbidden
+ }
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ return s.assetSigner.PutObject(context.Background(), asset.ObjectKey, contentType, body, size)
}
func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error) {
diff --git a/internal/http/api_test.go b/internal/http/api_test.go
index 528ff15..4fa82b5 100644
--- a/internal/http/api_test.go
+++ b/internal/http/api_test.go
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
"testing"
@@ -32,14 +33,14 @@ func newTestServer(adminPublicKey string) *httptest.Server {
type fakeSigner struct{}
-func (fakeSigner) SignedPutObjectURL(_ context.Context, objectKey string, _ time.Duration, _ string) (string, error) {
- return "http://files.local/upload/" + objectKey, nil
-}
-
func (fakeSigner) SignedGetObjectURL(_ context.Context, objectKey string, _ time.Duration) (string, error) {
return "http://files.local/download/" + objectKey, nil
}
+func (fakeSigner) PutObject(_ context.Context, _ string, _ string, _ io.Reader, _ int64) error {
+ return nil
+}
+
func mustJSON(t *testing.T, value interface{}) []byte {
t.Helper()
b, err := json.Marshal(value)
@@ -105,6 +106,25 @@ func patchJSON(t *testing.T, client *http.Client, url string, body interface{},
return resp, out
}
+func putRaw(t *testing.T, client *http.Client, url string, payload []byte, contentType string, token string) *http.Response {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(payload))
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("do request: %v", err)
+ }
+ return resp
+}
+
func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string {
t.Helper()
chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "")
@@ -368,6 +388,15 @@ func TestAssetLifecycleAndVisibility(t *testing.T) {
if uploadResp.StatusCode != http.StatusOK {
t.Fatalf("signed upload status=%d body=%v", uploadResp.StatusCode, uploadData)
}
+ if uploadData["url"] != "/v1/assets/"+assetID+"/upload" {
+ t.Fatalf("unexpected signed-upload backend url: %v", uploadData["url"])
+ }
+
+ putResp := putRaw(t, client, server.URL+"/v1/assets/"+assetID+"/upload", []byte("glb-bytes"), "model/gltf-binary", user1Token)
+ defer putResp.Body.Close()
+ if putResp.StatusCode != http.StatusNoContent {
+ t.Fatalf("upload proxy status=%d", putResp.StatusCode)
+ }
featuresResp, featuresData := getJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", user1Token)
if featuresResp.StatusCode != http.StatusOK {
diff --git a/internal/http/handlers.go b/internal/http/handlers.go
index 3f79c52..c3b5541 100644
--- a/internal/http/handlers.go
+++ b/internal/http/handlers.go
@@ -43,6 +43,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("POST /v1/assets", a.createAsset)
mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset)
mux.HandleFunc("POST /v1/assets/{id}/signed-upload", a.signedUpload)
+ mux.HandleFunc("PUT /v1/assets/{id}/upload", a.uploadAsset)
mux.HandleFunc("GET /v1/assets/{id}/download", a.downloadAsset)
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
@@ -459,6 +460,20 @@ func (a *API) signedUpload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"url": url, "method": http.MethodPut})
}
+func (a *API) uploadAsset(w http.ResponseWriter, r *http.Request) {
+ user, err := a.authUser(r)
+ if err != nil {
+ writeErr(w, err)
+ return
+ }
+ assetID := r.PathValue("id")
+ if err := a.service.UploadAsset(user, assetID, r.Header.Get("Content-Type"), r.Body, r.ContentLength); err != nil {
+ writeErr(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
diff --git a/internal/storage/s3_signer.go b/internal/storage/s3_signer.go
index 30c3b06..5759cbf 100644
--- a/internal/storage/s3_signer.go
+++ b/internal/storage/s3_signer.go
@@ -3,6 +3,7 @@ package storage
import (
"context"
"errors"
+ "io"
"time"
"github.com/minio/minio-go/v7"
@@ -47,14 +48,6 @@ func bucketLookup(pathStyle bool) minio.BucketLookupType {
return minio.BucketLookupAuto
}
-func (s *S3Signer) SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, _ string) (string, error) {
- u, err := s.client.PresignedPutObject(ctx, s.bucket, objectKey, expiry)
- if err != nil {
- return "", err
- }
- return u.String(), nil
-}
-
func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) {
u, err := s.client.PresignedGetObject(ctx, s.bucket, objectKey, expiry, nil)
if err != nil {
@@ -62,3 +55,10 @@ func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, exp
}
return u.String(), nil
}
+
+func (s *S3Signer) PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) error {
+ _, err := s.client.PutObject(ctx, s.bucket, objectKey, body, size, minio.PutObjectOptions{
+ ContentType: contentType,
+ })
+ return err
+}
diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts
index 09b5b89..d5e06da 100644
--- a/libs/geo-api-client/src/GeoApiClient.ts
+++ b/libs/geo-api-client/src/GeoApiClient.ts
@@ -265,10 +265,14 @@ export class GeoApiClient {
assetId: string,
contentType?: string
): Promise<{ url: string; method: string }> {
- return this.request(`/v1/assets/${assetId}/signed-upload`, {
+ const response = await this.request<{ url: string; method: string }>(`/v1/assets/${assetId}/signed-upload`, {
method: "POST",
body: { contentType: contentType ?? "application/octet-stream" },
});
+ if (response.url.startsWith("/")) {
+ response.url = this.resolveRelativeLink(response.url);
+ }
+ return response;
}
/** Update asset visibility (owner only). */
diff --git a/web/leaflet-demo.js b/web/leaflet-demo.js
index 00304b7..ed06acf 100644
--- a/web/leaflet-demo.js
+++ b/web/leaflet-demo.js
@@ -57,6 +57,13 @@ 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);
@@ -202,11 +209,31 @@ async function createFeatureAndUpload() {
isPublic: true,
});
const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream");
- const uploadRes = await fetch(signedUpload.url, {
- method: signedUpload.method || "PUT",
- headers: file.type ? { "Content-Type": file.type } : undefined,
- body: file,
- });
+ 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}`);
}
diff --git a/web/maplibre-demo.html b/web/maplibre-demo.html
new file mode 100644
index 0000000..8bef86b
--- /dev/null
+++ b/web/maplibre-demo.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+ MapLibre GL + Three.js Asset Demo
+
+
+
+
+
+
+
MapLibre GL + Three.js Demo
+
+ Vector tiles + 3D object placement. Click map to place, upload GLB/GLTF, store and share.
+
+
+
Connection
+
+
+
+
+
Auth
+
+
+
+
+
+
Collection
+
+
+
+
Current: none
+
+
Place + Upload 3D/Image
+
1) Click map for location 2) Select file 3) Upload and link
+
+
+
+
+
+
+
+
+
Stored Assets
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/maplibre-demo.js b/web/maplibre-demo.js
new file mode 100644
index 0000000..67ea2fc
--- /dev/null
+++ b/web/maplibre-demo.js
@@ -0,0 +1,383 @@
+import * as THREE from "https://unpkg.com/three@0.168.0/build/three.module.js";
+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 selectedLngLat = null;
+let pendingMarker = null;
+
+let map;
+let threeLayer;
+let threeScene;
+let threeRenderer;
+let threeCamera;
+const featureMeshes = new Map();
+
+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}`);
+}
+
+function createThreeLayer() {
+ return {
+ id: "threejs-custom-layer",
+ type: "custom",
+ renderingMode: "3d",
+ onAdd(m, gl) {
+ threeCamera = new THREE.Camera();
+ threeScene = new THREE.Scene();
+ const ambient = new THREE.AmbientLight(0xffffff, 0.8);
+ const directional = new THREE.DirectionalLight(0xffffff, 0.7);
+ directional.position.set(0, -70, 100).normalize();
+ threeScene.add(ambient);
+ threeScene.add(directional);
+
+ threeRenderer = new THREE.WebGLRenderer({
+ canvas: m.getCanvas(),
+ context: gl,
+ antialias: true,
+ });
+ threeRenderer.autoClear = false;
+ },
+ render(gl, matrix) {
+ const m = new THREE.Matrix4().fromArray(matrix);
+ threeCamera.projectionMatrix = m;
+ threeRenderer.resetState();
+ threeRenderer.render(threeScene, threeCamera);
+ map.triggerRepaint();
+ gl.disable(gl.DEPTH_TEST);
+ },
+ };
+}
+
+function disposeMesh(mesh) {
+ if (!mesh) return;
+ if (mesh.geometry) mesh.geometry.dispose();
+ if (mesh.material) {
+ if (Array.isArray(mesh.material)) {
+ for (const mat of mesh.material) mat.dispose();
+ } else {
+ mesh.material.dispose();
+ }
+ }
+}
+
+function clearFeatureMeshes() {
+ for (const mesh of featureMeshes.values()) {
+ threeScene.remove(mesh);
+ disposeMesh(mesh);
+ }
+ featureMeshes.clear();
+}
+
+function addObjectMesh(featureId, lng, lat, isPublic, kind) {
+ const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0);
+ const meters = merc.meterInMercatorCoordinateUnits();
+ const is3D = kind === "3d";
+ const geometry = is3D ? new THREE.BoxGeometry(1.2, 1.2, 2.2) : new THREE.PlaneGeometry(1.8, 1.8);
+ const color = isPublic ? 0x44dd88 : 0xdd5566;
+ const material = new THREE.MeshStandardMaterial({ color, transparent: true, opacity: 0.92 });
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.position.set(merc.x, merc.y, merc.z);
+ mesh.scale.setScalar(meters * (is3D ? 18 : 24));
+ if (!is3D) {
+ mesh.rotation.x = Math.PI / 2;
+ }
+ threeScene.add(mesh);
+ featureMeshes.set(featureId, mesh);
+}
+
+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() || "MapLibre 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 absoluteLink = client.resolveRelativeLink(asset.link);
+ const card = document.createElement("div");
+ card.className = "asset-card";
+ 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);
+ clearFeatureMeshes();
+ for (const feature of features) {
+ const coords = feature.geometry?.coordinates;
+ if (!coords || coords.length < 2) continue;
+ const lng = coords[0];
+ const lat = coords[1];
+ const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
+ const first = assets[0];
+ if (first) {
+ addObjectMesh(feature.id, lng, lat, first.isPublic, first.kind);
+ }
+ }
+ renderAssets(features);
+}
+
+async function createFeatureAndUpload() {
+ if (!selectedLngLat) {
+ 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,
+ selectedLngLat.lng,
+ selectedLngLat.lat,
+ { name: featureName, placement: "maplibre-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. ` +
+ `Use a public S3 endpoint host for signed URLs or add an API upload proxy.`
+ );
+ }
+
+ let uploadRes;
+ try {
+ uploadRes = await fetch(signedUpload.url, {
+ method: signedUpload.method || "PUT",
+ headers: file.type ? { "Content-Type": file.type } : undefined,
+ body: file,
+ });
+ } catch {
+ throw new Error("Network error while uploading. Check S3 endpoint reachability and CORS policy.");
+ }
+ 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 rendered on map.");
+}
+
+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);
+ }
+};
+
+map = new maplibregl.Map({
+ container: "map",
+ style: "https://demotiles.maplibre.org/style.json",
+ center: [-16.2518, 28.4636],
+ zoom: 12,
+ pitch: 55,
+ bearing: -15,
+ hash: false,
+ antialias: true,
+});
+map.addControl(new maplibregl.NavigationControl(), "top-right");
+
+map.on("load", () => {
+ threeLayer = createThreeLayer();
+ map.addLayer(threeLayer);
+});
+
+map.on("click", (event) => {
+ selectedLngLat = event.lngLat;
+ if (pendingMarker) pendingMarker.remove();
+ pendingMarker = new maplibregl.Marker({ color: "#22d3ee" })
+ .setLngLat(event.lngLat)
+ .addTo(map);
+ setStatus(`Selected location: ${event.lngLat.lat.toFixed(5)}, ${event.lngLat.lng.toFixed(5)}`);
+});
+
+const savedBase = localStorage.getItem("geo_api_base");
+if (savedBase) {
+ apiBaseEl.value = savedBase;
+ setClientBase(savedBase);
+}