This adds typed asset APIs to the geo client, covers the 3D/image upload-share flow in integration tests, and introduces a simple Leaflet web demo that places objects on map features and manages sharing visibility via backend links. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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";
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div><strong>${asset.kind}</strong> • ${asset.ext}</div>
|
||||
<div class="muted">Feature: ${feature.id}</div>
|
||||
<div class="muted">Visibility: ${asset.isPublic ? "public" : "private"}</div>
|
||||
<div class="muted">Link: ${asset.link}</div>
|
||||
`;
|
||||
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 = `<div class="muted">No assets linked yet.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
const uploadRes = await fetch(signedUpload.url, {
|
||||
method: signedUpload.method || "PUT",
|
||||
headers: file.type ? { "Content-Type": file.type } : undefined,
|
||||
body: file,
|
||||
});
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user