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:
+120
-1
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user