Files
backend/web/index.html
Andriy Oblivantsev e1107256e8
CI / test (push) Successful in 3s
Vendor frontend CDN dependencies and serve them locally.
This switches demo pages and modules to local web/vendor assets, fixes Three GLTFLoader local import resolution, and documents the runtime-data/agent commit workflow updates.

Made-with: Cursor
2026-03-02 22:43:27 +00:00

210 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Momswap Geo Backend Use-Cases Test</title>
<link href="./vendor/vuetify/vuetify.min.css" rel="stylesheet" />
<style>
body {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
.glass {
backdrop-filter: blur(8px);
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(148, 163, 184, 0.2);
}
</style>
</head>
<body>
<div id="app">
<v-app theme="dark">
<v-container class="py-8">
<v-row>
<v-col cols="12">
<v-card class="glass rounded-xl pa-6">
<v-card-title class="text-h4 mb-2">Momswap Geo Backend Use-Cases Test</v-card-title>
<v-card-subtitle>Ed25519 auth, invitation onboarding, and user-scoped feature collections</v-card-subtitle>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-card class="glass rounded-xl pa-4">
<v-card-title>Connection & Identity</v-card-title>
<v-text-field v-model="apiBase" label="API Base URL"></v-text-field>
<v-btn color="primary" @click="rebuildClient">Apply API URL</v-btn>
<v-divider class="my-4"></v-divider>
<v-btn color="secondary" @click="ensureKeys">Ensure Keys in localStorage</v-btn>
<v-textarea v-model="state.publicKey" class="mt-3" label="Public Key (pb)" rows="2"></v-textarea>
<v-textarea v-model="state.privateKey" label="Private Key (pk, local only)" rows="2"></v-textarea>
<v-btn size="small" variant="outlined" class="mt-2" @click="restorePublicKeyFromPrivate">Restore Public Key from Private Key</v-btn>
<v-btn size="small" variant="outlined" color="primary" class="mt-2" @click="importPkFromCamera">Import pk from camera</v-btn>
<v-dialog v-model="state.showCameraDialog" max-width="400" persistent @click:outside="closeCameraDialog">
<v-card class="pa-4">
<v-card-title>Scan private key (pk) QR</v-card-title>
<v-card-text>
<video id="camera-video" autoplay playsinline muted class="rounded" style="width:100%;max-height:300px;background:#000"></video>
<v-alert v-if="state.cameraError" type="error" density="compact" class="mt-2">{{ state.cameraError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="closeCameraDialog">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-divider class="my-3"></v-divider>
<v-row v-if="state.qrPk || state.qrPb" dense>
<v-col v-if="state.qrPk" cols="auto">
<div class="text-caption text-error mb-1">QR: Private Key (pk, secret backup)</div>
<img v-show="state.showPrivateQR" :src="state.qrPk" alt="Private key QR" width="140" height="140" class="rounded" />
<v-btn size="small" variant="outlined" color="warning" @click="togglePrivateQR" class="mt-1">
{{ state.showPrivateQR ? 'Hide' : 'Show' }} private key (pk) QR
</v-btn>
</v-col>
<v-col v-if="state.qrPb" cols="auto">
<div v-if="state.showPublicQR">
<div class="text-caption text-medium-emphasis mb-1">QR: Public Key (pb)</div>
<img :src="state.qrPb" alt="Public key QR" width="140" height="140" class="rounded" />
</div>
<v-btn size="small" variant="outlined" @click="togglePublicQR" class="mt-1">
{{ state.showPublicQR ? 'Hide' : 'Show' }} public key (pb) QR
</v-btn>
</v-col>
</v-row>
<v-btn color="secondary" class="mt-2" @click="register">Register (sign service key)</v-btn>
<v-btn color="success" class="mt-2" @click="login">Login</v-btn>
</v-card>
</v-col>
<v-col v-if="state.accessToken" cols="12" md="6">
<v-card class="glass rounded-xl pa-4">
<v-card-title>Collections</v-card-title>
<v-text-field v-model="state.newCollectionName" label="New collection name" density="compact" hide-details class="mb-2"></v-text-field>
<v-btn color="primary" @click="createCollection" size="small" class="mr-2">Create Collection</v-btn>
<v-btn variant="outlined" @click="listCollections" size="small">Refresh</v-btn>
<v-list class="mt-3" density="compact">
<v-list-item
v-for="item in state.collections"
:key="item.id"
:active="state.selectedCollectionId === item.id"
@click="selectCollection(item.id)"
class="cursor-pointer rounded"
>
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">{{ item.id }}</v-list-item-subtitle>
<template v-slot:append>
<v-btn icon variant="text" size="small" color="error" @click.stop="removeCollection(item.id)" aria-label="Remove">×</v-btn>
</template>
</v-list-item>
</v-list>
<v-card v-if="selectedCollection()" class="glass mt-4 pa-4" variant="tonal">
<v-card-title class="d-flex align-center flex-wrap gap-2">
<v-text-field
v-model="state.editingCollectionName"
@blur="onCollectionNameBlur"
density="compact"
hide-details
label="Collection name"
class="flex-grow-1"
style="max-width: 200px"
></v-text-field>
<v-btn variant="outlined" color="error" size="small" @click="removeCollection(selectedCollection().id)">Remove collection</v-btn>
<v-btn variant="text" size="small" @click="listFeatures(selectedCollection().id)">Refresh</v-btn>
</v-card-title>
<v-card-subtitle class="mb-2">Features (GeoJSON)</v-card-subtitle>
<v-row dense class="mb-3">
<v-col cols="6" sm="2">
<v-text-field v-model="state.newFeatureLon" label="Lon" type="number" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="6" sm="2">
<v-text-field v-model="state.newFeatureLat" label="Lat" type="number" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="8" sm="3">
<v-text-field v-model="state.newFeatureName" label="Name" density="compact" hide-details placeholder="Point"></v-text-field>
</v-col>
<v-col cols="4" sm="2">
<v-btn color="primary" @click="createFeature(selectedCollection().id)" size="small">Add feature</v-btn>
</v-col>
</v-row>
<div v-for="f in featuresFor(selectedCollection().id)" :key="f.id" class="d-flex align-center py-2 mb-1 rounded px-2" style="background: rgba(255,255,255,0.05)">
<span class="flex-grow-1">{{ formatFeature(f).name }} ({{ formatFeature(f).lon }}, {{ formatFeature(f).lat }})</span>
<v-btn
variant="tonal"
size="small"
class="mr-2"
:color="state.selectedFeatureId === f.id ? 'primary' : undefined"
@click="selectFeature(f.id)"
>
{{ state.selectedFeatureId === f.id ? 'Selected' : 'Select' }}
</v-btn>
<v-btn variant="outlined" color="error" size="small" @click="removeFeature(selectedCollection().id, f.id)">Remove</v-btn>
</div>
<div v-if="featuresFor(selectedCollection().id).length === 0" class="text-medium-emphasis text-caption py-2">No features. Add a point above.</div>
<v-divider class="my-4"></v-divider>
<v-card variant="tonal" class="pa-3">
<v-card-subtitle class="mb-2">Asset example flow (images + 3D)</v-card-subtitle>
<div class="text-caption mb-2">
Select a feature, choose a file, then upload. The backend stores metadata and links it under
<code>feature.properties.assets</code>.
</div>
<v-alert v-if="!selectedFeature()" type="info" variant="tonal" density="compact" class="mb-2">
Select a feature to enable asset actions.
</v-alert>
<div v-else class="text-caption mb-2">
Target feature: <strong>{{ formatFeature(selectedFeature()).name }}</strong> ({{ selectedFeature().id }})
</div>
<input type="file" @change="onAssetFileChange" :disabled="!selectedFeature()" />
<div class="text-caption mt-1 mb-2" v-if="state.selectedAssetFileName">Selected: {{ state.selectedAssetFileName }}</div>
<v-row dense>
<v-col cols="12" sm="4">
<v-text-field v-model="state.newAssetName" label="Asset name" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="state.newAssetDescription" label="Asset description" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-btn color="primary" size="small" :disabled="!selectedFeature()" @click="createAndUploadAsset">Upload</v-btn>
</v-col>
</v-row>
<div v-if="selectedFeature() && formatFeature(selectedFeature()).assets.length > 0" class="mt-3">
<div class="text-caption mb-2">Linked assets:</div>
<div
v-for="asset in formatFeature(selectedFeature()).assets"
:key="asset.id"
class="d-flex align-center py-2 px-2 mb-1 rounded"
style="background: rgba(255,255,255,0.05)"
>
<div class="flex-grow-1 text-caption">
<div><strong>{{ asset.kind }}</strong> • {{ asset.ext }} • {{ asset.id }}</div>
<div>Visibility: {{ asset.isPublic ? "public" : "private" }}</div>
</div>
<v-btn variant="outlined" size="small" class="mr-2" @click="openAssetLink(asset)">Open</v-btn>
<v-btn variant="tonal" size="small" @click="toggleAssetVisibility(asset)">
Set {{ asset.isPublic ? "Private" : "Public" }}
</v-btn>
</div>
</div>
</v-card>
</v-card>
</v-card>
</v-col>
<v-row>
<v-col cols="12">
<v-alert type="info" variant="tonal">{{ state.status }}</v-alert>
</v-col>
</v-row>
</v-container>
</v-app>
</div>
<script src="./vendor/vue/vue.global.prod.js"></script>
<script src="./vendor/vuetify/vuetify.min.js"></script>
<script type="module" src="./app.js"></script>
</body>
</html>