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: {}, newFeatureLon: "", newFeatureLat: "", newFeatureName: "", status: "Ready", qrPk: "", qrPb: "", showPrivateQR: true, showPublicQR: false, showCameraDialog: false, cameraError: "", cameraAbortController: 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 || []; } 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 ?? "—"; return { id: f.id, name, lon, lat }; }; watch( () => [state.publicKey, state.privateKey], () => refreshQRCodes(), { immediate: false } ); watch( () => state.selectedCollectionId, (id) => { if (id) listFeatures(id); } ); const selectCollection = (id) => { state.selectedCollectionId = id; 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, togglePrivateQR, togglePublicQR, }; }, }).use(Vuetify.createVuetify()).mount("#app");