Files
backend/web/app.js
Andriy Oblivantsev 8a3cd2c27e
CI / test (push) Successful in 5s
Import pk from camera, QR visibility toggles, docs
- Add Import pk from camera: scan QR → restore pb → auto login → refresh
- Add scanner.js (jsQR) for camera QR decode
- QR visibility: pk shown by default, pb hidden by default (toggles)
- Update docs/frontend-development.md with scanner, Import pk, QR behavior

Made-with: Cursor
2026-03-01 14:05:43 +00:00

346 lines
10 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://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");