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,
};