Files
backend/web/app.js
Andriy Oblivantsev efe5907adc
CI / test (push) Successful in 3s
Update docs and defaults for tenerife.baby domain.
This replaces old momswap.produktor.duckdns.org references with tenerife.baby and refreshes the TypeScript integration guide to reflect the current asset upload, sharing, and relative-link flow.

Made-with: Cursor
2026-03-02 21:31:21 +00:00

465 lines
14 KiB
JavaScript

import { createApiClient } from "./api.js";
import { toDataURL } from "./qr.js";
import { scanQRFromCamera } from "./scanner.js";
const { createApp, ref, reactive, onMounted, watch } = Vue;
createApp({
setup() {
const apiBase = ref(localStorage.getItem("geo_api_base") || "https://tenerife.baby");
const state = reactive({
publicKey: "",
privateKey: "",
accessToken: "",
collections: [],
newCollectionName: "",
selectedCollectionId: "",
editingCollectionName: "",
featuresByCollection: {},
selectedFeatureId: "",
newFeatureLon: "",
newFeatureLat: "",
newFeatureName: "",
newAssetName: "",
newAssetDescription: "",
selectedAssetFileName: "",
status: "Ready",
qrPk: "",
qrPb: "",
showPrivateQR: true,
showPublicQR: false,
showCameraDialog: false,
cameraError: "",
cameraAbortController: null,
});
const selectedAssetFile = ref(null);
let client = createApiClient(apiBase.value);
const rebuildClient = () => {
client = createApiClient(apiBase.value);
localStorage.setItem("geo_api_base", apiBase.value);
state.status = `API base set to ${apiBase.value}`;
};
const refreshQRCodes = async () => {
if (!state.publicKey) {
state.qrPk = "";
state.qrPb = "";
return;
}
try {
state.qrPb = await toDataURL(state.publicKey);
if (state.privateKey) {
state.qrPk = await toDataURL(state.privateKey, { dark: "#7f1d1d" });
} else {
state.qrPb = "";
}
} catch (err) {
state.qrPk = "";
state.qrPb = "";
}
};
const ensureKeys = async () => {
try {
const keys = await client.ensureKeysInStorage();
state.publicKey = keys.publicKey;
state.privateKey = keys.privateKey;
state.status = "Keys loaded from localStorage.";
await refreshQRCodes();
} catch (err) {
state.status = err.message;
}
};
const restorePublicKeyFromPrivate = async () => {
if (!state.privateKey?.trim()) {
state.status = "Enter private key (pk) first.";
return;
}
try {
const pb = await client.derivePublicKey(state.privateKey.trim());
state.publicKey = pb;
client.importKeys({ publicKey: pb, privateKey: state.privateKey.trim() });
await refreshQRCodes();
state.status = "Public key (pb) restored from private key (pk).";
} catch (err) {
state.status = err.message;
}
};
const closeCameraDialog = () => {
state.showCameraDialog = false;
if (state.cameraAbortController) {
state.cameraAbortController.abort();
state.cameraAbortController = null;
}
state.cameraError = "";
};
const runCameraScan = async () => {
state.cameraError = "";
state.cameraAbortController = new AbortController();
const videoEl = document.getElementById("camera-video");
if (!videoEl) {
state.cameraError = "Video element not found.";
return;
}
try {
const pk = await scanQRFromCamera(videoEl, state.cameraAbortController.signal);
const pkTrimmed = pk.trim();
closeCameraDialog();
state.privateKey = pkTrimmed;
const pb = await client.derivePublicKey(pkTrimmed);
state.publicKey = pb;
client.importKeys({ publicKey: pb, privateKey: pkTrimmed });
await refreshQRCodes();
try {
try {
await client.registerBySigningServiceKey(pb, pkTrimmed);
} catch (err) {
const msg = (err?.message || "").toLowerCase();
const ignore = msg.includes("already registered") || msg.includes("not configured") ||
msg.includes("admin_public_key") || msg.includes("409") || msg.includes("conflict");
if (!ignore) throw err;
}
state.accessToken = await client.loginWithSignature(pb, pkTrimmed);
client.setAccessToken(state.accessToken);
await listCollections();
state.status = "Imported pk from camera, restored pb, logged in.";
} catch (err) {
state.status = `Keys imported. Login failed: ${err.message}`;
}
} catch (err) {
if (err.name === "AbortError") return;
state.cameraError = err.message || "Camera access failed.";
}
};
const importPkFromCamera = () => {
state.showCameraDialog = true;
Vue.nextTick(() => runCameraScan());
};
const register = async () => {
try {
await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
state.status = "Registered. Use Login to authenticate.";
} catch (err) {
state.status = err.message;
}
};
const login = async () => {
try {
try {
await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
} catch (err) {
const msg = (err?.message || "").toLowerCase();
const ignore = msg.includes("already registered") || msg.includes("not configured") ||
msg.includes("admin_public_key") || msg.includes("409") || msg.includes("conflict");
if (!ignore) throw err;
// Proceed to login: already registered or registration disabled (invitation flow)
}
state.accessToken = await client.loginWithSignature(state.publicKey, state.privateKey);
client.setAccessToken(state.accessToken);
await listCollections();
state.status = "Authenticated.";
} catch (err) {
state.status = err.message;
}
};
const listCollections = async () => {
try {
client.setAccessToken(state.accessToken);
const data = await client.listCollections();
state.collections = data.collections || [];
} catch (err) {
state.status = err.message;
}
};
const createCollection = async () => {
try {
client.setAccessToken(state.accessToken);
await client.createCollection(state.newCollectionName);
state.newCollectionName = "";
await listCollections();
} catch (err) {
state.status = err.message;
}
};
const removeCollection = async (collectionId) => {
try {
client.setAccessToken(state.accessToken);
await client.deleteCollection(collectionId);
if (state.selectedCollectionId === collectionId) {
state.selectedCollectionId = "";
}
delete state.featuresByCollection[collectionId];
await listCollections();
} catch (err) {
state.status = err.message;
}
};
const listFeatures = async (collectionId) => {
if (!collectionId) return;
try {
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;
}
};
const createFeature = async (collectionId) => {
if (!collectionId) return;
const lon = parseFloat(state.newFeatureLon);
const lat = parseFloat(state.newFeatureLat);
if (isNaN(lon) || isNaN(lat)) {
state.status = "Enter valid lon/lat numbers.";
return;
}
if (lon < -180 || lon > 180) {
state.status = "Longitude must be -180 to 180.";
return;
}
if (lat < -90 || lat > 90) {
state.status = "Latitude must be -90 to 90.";
return;
}
try {
client.setAccessToken(state.accessToken);
await client.createPointFeature(collectionId, lon, lat, {
name: state.newFeatureName || "Point",
});
state.newFeatureLon = "";
state.newFeatureLat = "";
state.newFeatureName = "";
await listFeatures(collectionId);
} catch (err) {
state.status = err.message;
}
};
const removeFeature = async (collectionId, featureId) => {
try {
client.setAccessToken(state.accessToken);
await client.deleteFeature(featureId);
await listFeatures(collectionId);
} catch (err) {
state.status = err.message;
}
};
const featuresFor = (collectionId) => state.featuresByCollection[collectionId] ?? [];
const formatFeature = (f) => {
const coords = f.geometry?.coordinates;
const lon = coords?.[0] ?? "—";
const lat = coords?.[1] ?? "—";
const name = f.properties?.name ?? f.id ?? "—";
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(
() => [state.publicKey, state.privateKey],
() => refreshQRCodes(),
{ immediate: false }
);
watch(
() => state.selectedCollectionId,
(id) => {
if (id) listFeatures(id);
}
);
const selectCollection = (id) => {
state.selectedCollectionId = id;
state.selectedFeatureId = "";
const c = state.collections.find((x) => x.id === id);
state.editingCollectionName = c ? c.name : "";
};
const selectedCollection = () =>
state.collections.find((c) => c.id === state.selectedCollectionId);
const updateCollectionName = async (collectionId, newName) => {
if (!newName?.trim()) return;
try {
client.setAccessToken(state.accessToken);
const updated = await client.updateCollection(collectionId, newName.trim());
const idx = state.collections.findIndex((c) => c.id === collectionId);
if (idx >= 0) state.collections[idx] = updated;
state.editingCollectionName = updated.name;
} catch (err) {
state.status = err.message;
}
};
const onCollectionNameBlur = () => {
const c = selectedCollection();
if (c && state.editingCollectionName !== c.name) {
updateCollectionName(c.id, state.editingCollectionName);
}
};
onMounted(async () => {
await ensureKeys();
});
const togglePrivateQR = () => {
state.showPrivateQR = !state.showPrivateQR;
};
const togglePublicQR = () => {
state.showPublicQR = !state.showPublicQR;
};
return {
apiBase,
state,
rebuildClient,
ensureKeys,
restorePublicKeyFromPrivate,
importPkFromCamera,
closeCameraDialog,
register,
login,
listCollections,
createCollection,
removeCollection,
listFeatures,
selectCollection,
selectedCollection,
updateCollectionName,
onCollectionNameBlur,
createFeature,
removeFeature,
formatFeature,
featuresFor,
selectFeature,
selectedFeature,
onAssetFileChange,
createAndUploadAsset,
toggleAssetVisibility,
openAssetLink,
togglePrivateQR,
togglePublicQR,
};
},
}).use(Vuetify.createVuetify()).mount("#app");