Extend TypeScript client and add Leaflet asset demo.
CI / test (pull_request) Successful in 3s

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:
2026-03-02 21:21:52 +00:00
parent f6f46f6db1
commit 1292f204a4
14 changed files with 1009 additions and 47 deletions
+120 -1
View File
@@ -16,9 +16,13 @@ createApp({
selectedCollectionId: "",
editingCollectionName: "",
featuresByCollection: {},
selectedFeatureId: "",
newFeatureLon: "",
newFeatureLat: "",
newFeatureName: "",
newAssetName: "",
newAssetDescription: "",
selectedAssetFileName: "",
status: "Ready",
qrPk: "",
qrPb: "",
@@ -28,6 +32,7 @@ createApp({
cameraError: "",
cameraAbortController: null,
});
const selectedAssetFile = ref(null);
let client = createApiClient(apiBase.value);
@@ -207,6 +212,12 @@ createApp({
client.setAccessToken(state.accessToken);
const data = await client.listFeatures(collectionId);
state.featuresByCollection[collectionId] = data.features || [];
if (
state.selectedFeatureId &&
!(state.featuresByCollection[collectionId] || []).some((f) => f.id === state.selectedFeatureId)
) {
state.selectedFeatureId = "";
}
} catch (err) {
state.status = err.message;
}
@@ -259,7 +270,108 @@ createApp({
const lon = coords?.[0] ?? "—";
const lat = coords?.[1] ?? "—";
const name = f.properties?.name ?? f.id ?? "—";
return { id: f.id, name, lon, lat };
const assets = Array.isArray(f.properties?.assets) ? f.properties.assets : [];
return { id: f.id, name, lon, lat, assets };
};
const selectFeature = (featureId) => {
state.selectedFeatureId = featureId;
};
const selectedFeature = () => {
const collectionId = state.selectedCollectionId;
if (!collectionId || !state.selectedFeatureId) return null;
return (state.featuresByCollection[collectionId] || []).find((f) => f.id === state.selectedFeatureId) ?? null;
};
const fileExtension = (name) => {
const idx = name.lastIndexOf(".");
if (idx <= 0) return "";
return name.slice(idx + 1).toLowerCase();
};
const kindFromExtension = (ext) => (ext === "gltf" || ext === "glb" ? "3d" : "image");
const hashFileSha256 = async (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("");
};
const onAssetFileChange = (event) => {
const target = event?.target;
const file = target?.files?.[0] ?? null;
selectedAssetFile.value = file;
state.selectedAssetFileName = file ? file.name : "";
};
const createAndUploadAsset = async () => {
const feature = selectedFeature();
if (!feature) {
state.status = "Select a feature first.";
return;
}
if (!selectedAssetFile.value) {
state.status = "Select a file first.";
return;
}
const file = selectedAssetFile.value;
const ext = fileExtension(file.name);
if (!ext) {
state.status = "File extension is required.";
return;
}
try {
client.setAccessToken(state.accessToken);
const checksum = await hashFileSha256(file);
const kind = kindFromExtension(ext);
const created = await client.createOrLinkAsset({
featureId: feature.id,
checksum,
ext,
kind,
mimeType: file.type || "application/octet-stream",
sizeBytes: file.size,
name: state.newAssetName || file.name,
description: state.newAssetDescription,
isPublic: true,
});
const upload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream");
const putRes = await fetch(upload.url, {
method: upload.method || "PUT",
headers: file.type ? { "Content-Type": file.type } : undefined,
body: file,
});
if (!putRes.ok) {
throw new Error(`Upload failed with status ${putRes.status}`);
}
state.newAssetName = "";
state.newAssetDescription = "";
state.selectedAssetFileName = "";
selectedAssetFile.value = null;
await listFeatures(state.selectedCollectionId);
state.status = "Asset uploaded and linked to feature.";
} catch (err) {
state.status = err.message;
}
};
const toggleAssetVisibility = async (asset) => {
try {
client.setAccessToken(state.accessToken);
await client.setAssetVisibility(asset.id, !asset.isPublic);
await listFeatures(state.selectedCollectionId);
state.status = `Asset visibility updated: ${asset.id}`;
} catch (err) {
state.status = err.message;
}
};
const openAssetLink = (asset) => {
const absolute = client.resolveRelativeLink(asset.link);
window.open(absolute, "_blank", "noopener,noreferrer");
};
watch(
@@ -277,6 +389,7 @@ createApp({
const selectCollection = (id) => {
state.selectedCollectionId = id;
state.selectedFeatureId = "";
const c = state.collections.find((x) => x.id === id);
state.editingCollectionName = c ? c.name : "";
};
@@ -338,6 +451,12 @@ createApp({
removeFeature,
formatFeature,
featuresFor,
selectFeature,
selectedFeature,
onAssetFileChange,
createAndUploadAsset,
toggleAssetVisibility,
openAssetLink,
togglePrivateQR,
togglePublicQR,
};
+56
View File
@@ -130,9 +130,65 @@
</v-row>
<div v-for="f in featuresFor(selectedCollection().id)" :key="f.id" class="d-flex align-center py-2 mb-1 rounded px-2" style="background: rgba(255,255,255,0.05)">
<span class="flex-grow-1">{{ formatFeature(f).name }} ({{ formatFeature(f).lon }}, {{ formatFeature(f).lat }})</span>
<v-btn
variant="tonal"
size="small"
class="mr-2"
:color="state.selectedFeatureId === f.id ? 'primary' : undefined"
@click="selectFeature(f.id)"
>
{{ state.selectedFeatureId === f.id ? 'Selected' : 'Select' }}
</v-btn>
<v-btn variant="outlined" color="error" size="small" @click="removeFeature(selectedCollection().id, f.id)">Remove</v-btn>
</div>
<div v-if="featuresFor(selectedCollection().id).length === 0" class="text-medium-emphasis text-caption py-2">No features. Add a point above.</div>
<v-divider class="my-4"></v-divider>
<v-card variant="tonal" class="pa-3">
<v-card-subtitle class="mb-2">Asset example flow (images + 3D)</v-card-subtitle>
<div class="text-caption mb-2">
Select a feature, choose a file, then upload. The backend stores metadata and links it under
<code>feature.properties.assets</code>.
</div>
<v-alert v-if="!selectedFeature()" type="info" variant="tonal" density="compact" class="mb-2">
Select a feature to enable asset actions.
</v-alert>
<div v-else class="text-caption mb-2">
Target feature: <strong>{{ formatFeature(selectedFeature()).name }}</strong> ({{ selectedFeature().id }})
</div>
<input type="file" @change="onAssetFileChange" :disabled="!selectedFeature()" />
<div class="text-caption mt-1 mb-2" v-if="state.selectedAssetFileName">Selected: {{ state.selectedAssetFileName }}</div>
<v-row dense>
<v-col cols="12" sm="4">
<v-text-field v-model="state.newAssetName" label="Asset name" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="state.newAssetDescription" label="Asset description" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-btn color="primary" size="small" :disabled="!selectedFeature()" @click="createAndUploadAsset">Upload</v-btn>
</v-col>
</v-row>
<div v-if="selectedFeature() && formatFeature(selectedFeature()).assets.length > 0" class="mt-3">
<div class="text-caption mb-2">Linked assets:</div>
<div
v-for="asset in formatFeature(selectedFeature()).assets"
:key="asset.id"
class="d-flex align-center py-2 px-2 mb-1 rounded"
style="background: rgba(255,255,255,0.05)"
>
<div class="flex-grow-1 text-caption">
<div><strong>{{ asset.kind }}</strong> • {{ asset.ext }} • {{ asset.id }}</div>
<div>Visibility: {{ asset.isPublic ? "public" : "private" }}</div>
</div>
<v-btn variant="outlined" size="small" class="mr-2" @click="openAssetLink(asset)">Open</v-btn>
<v-btn variant="tonal" size="small" @click="toggleAssetVisibility(asset)">
Set {{ asset.isPublic ? "Private" : "Public" }}
</v-btn>
</div>
</div>
</v-card>
</v-card>
</v-card>
</v-col>
+153
View File
@@ -0,0 +1,153 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Momswap Leaflet 3D Assets Demo</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b1220;
color: #dbe5f6;
}
.layout {
display: grid;
grid-template-columns: 360px 1fr;
gap: 12px;
height: 100vh;
padding: 12px;
box-sizing: border-box;
}
.panel {
background: #10192c;
border: 1px solid #24344f;
border-radius: 12px;
padding: 12px;
overflow: auto;
}
h1 {
font-size: 18px;
margin: 0 0 10px;
}
h2 {
font-size: 14px;
margin: 14px 0 8px;
}
label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: #9eb0ce;
}
input,
button,
select {
width: 100%;
box-sizing: border-box;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid #33496a;
background: #0d1525;
color: #e4ecfa;
padding: 8px 10px;
}
button {
cursor: pointer;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.muted {
font-size: 12px;
color: #9eb0ce;
}
.status {
font-size: 12px;
color: #8ee3a1;
min-height: 18px;
}
#map {
border-radius: 12px;
border: 1px solid #24344f;
}
.asset-card {
border: 1px solid #314869;
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
}
.asset-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
}
</style>
</head>
<body>
<div class="layout">
<div class="panel">
<h1>Leaflet 3D Asset Sharing Demo</h1>
<div class="muted">Click map to place an object, upload asset, then share via backend link.</div>
<h2>Connection</h2>
<label for="apiBase">API Base URL</label>
<input id="apiBase" value="http://localhost:8122" />
<button id="applyApi">Apply API URL</button>
<h2>Auth</h2>
<button id="ensureKeys">Ensure Keys</button>
<button id="register">Register</button>
<button id="login">Login</button>
<div class="muted" id="publicKeyPreview"></div>
<h2>Collection</h2>
<label for="collectionName">Name</label>
<input id="collectionName" placeholder="3D objects" value="3D objects demo" />
<button id="createCollection">Create Collection</button>
<div class="muted">Current: <span id="collectionInfo">none</span></div>
<h2>Place + Upload Asset</h2>
<div class="muted">1) Click map to choose location. 2) Select file. 3) Upload and link.</div>
<label for="assetFile">3D/Image file</label>
<input id="assetFile" type="file" accept=".gltf,.glb,.jpg,.jpeg,.png,.webp" />
<label for="assetName">Asset name</label>
<input id="assetName" placeholder="Palm Tree" />
<label for="assetDesc">Description</label>
<input id="assetDesc" placeholder="Low-poly palm tree" />
<button id="uploadAsset">Create Feature + Upload + Link</button>
<h2>Stored Assets</h2>
<div id="assetsList"></div>
<div class="status" id="status"></div>
</div>
<div id="map"></div>
</div>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script type="module" src="./leaflet-demo.js"></script>
</body>
</html>
+274
View File
@@ -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: "&copy; 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);
}