CI / test (push) Successful in 4s
This prevents stale localStorage geo_api_base values from forcing localhost API calls on production domains by defaulting to window.location.origin when appropriate. Made-with: Cursor
476 lines
15 KiB
JavaScript
476 lines
15 KiB
JavaScript
import { createApiClient } from "./api.js";
|
|
import { toDataURL } from "./qr.js";
|
|
import { scanQRFromCamera } from "./scanner.js";
|
|
|
|
const { createApp, ref, reactive, onMounted, watch } = Vue;
|
|
|
|
function normalizeInitialApiBase() {
|
|
const saved = localStorage.getItem("geo_api_base") || "";
|
|
const host = window.location.hostname.toLowerCase();
|
|
if (saved) {
|
|
const pointsToLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(saved);
|
|
const runningHosted = host !== "localhost" && host !== "127.0.0.1";
|
|
if (!(runningHosted && pointsToLocalhost)) return saved;
|
|
}
|
|
return window.location.origin;
|
|
}
|
|
|
|
createApp({
|
|
setup() {
|
|
const apiBase = ref(normalizeInitialApiBase());
|
|
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");
|