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://momswap.produktor.duckdns.org"); 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");