This adds typed asset APIs to the geo client, covers the 3D/image upload-share flow in integration tests, and introduces a simple Leaflet web demo that places objects on map features and manages sharing visibility via backend links. Made-with: Cursor
This commit is contained in:
@@ -89,6 +89,7 @@ Then visit:
|
||||
|
||||
- Production: `https://momswap.produktor.duckdns.org/web/`
|
||||
- Local: `http://localhost:8122/web/`
|
||||
- Local Leaflet demo: `http://localhost:8122/web/leaflet-demo.html`
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -38,12 +38,42 @@ Each `properties.assets` item includes:
|
||||
5. Change visibility:
|
||||
- `PATCH /v1/assets/{id}` with `{"isPublic": false|true}`
|
||||
|
||||
## Example asset payload inside a feature
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "feature-id",
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Palm Tree Spot",
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-id",
|
||||
"kind": "3d",
|
||||
"name": "Palm Tree",
|
||||
"description": "Low-poly tree",
|
||||
"checksum": "abc123...",
|
||||
"ext": "glb",
|
||||
"isPublic": true,
|
||||
"link": "/v1/assets/asset-id/download"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Visibility rules
|
||||
|
||||
- Owner can always download their own asset.
|
||||
- Other authenticated users can download only when `isPublic=true`.
|
||||
- Owner can toggle `isPublic` at any time.
|
||||
|
||||
## Deduplication behavior
|
||||
|
||||
- Deduplication is per owner and file identity (`owner_key + checksum + ext`).
|
||||
- If the same user submits the same checksum/extension again, backend reuses existing asset metadata.
|
||||
- One asset can be linked to multiple features without duplicating object storage files.
|
||||
|
||||
## Spatial readiness for 3D search
|
||||
|
||||
Feature storage is prepared for future spatial search:
|
||||
|
||||
@@ -43,3 +43,30 @@ docker compose up --build -d
|
||||
- create asset and get signed upload URL
|
||||
- upload file with PUT
|
||||
- request `/v1/assets/{id}/download`
|
||||
|
||||
## Quick verification script
|
||||
|
||||
Use this as a smoke-check after startup:
|
||||
|
||||
```bash
|
||||
# 1) check API and MinIO UI reachability
|
||||
curl -fsS http://localhost:8122/healthz
|
||||
curl -I http://localhost:8774
|
||||
|
||||
# 2) ensure MinIO S3 API is not exposed on host
|
||||
if curl -fsS http://localhost:9000/minio/health/live >/dev/null 2>&1; then
|
||||
echo "Unexpected: MinIO S3 API is exposed on host"
|
||||
else
|
||||
echo "OK: MinIO S3 API is internal-only"
|
||||
fi
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `api` fails with storage config errors, verify `S3_*` variables in compose environment.
|
||||
- If bucket bootstrap fails, inspect:
|
||||
- `docker compose logs minio`
|
||||
- `docker compose logs minio-init`
|
||||
- If signed URLs are generated but upload fails, check:
|
||||
- object key path style (`S3_USE_PATH_STYLE=true` for MinIO)
|
||||
- MinIO credentials (`S3_ACCESS_KEY`, `S3_SECRET_KEY`)
|
||||
|
||||
@@ -1,60 +1,69 @@
|
||||
# Frontend Development
|
||||
|
||||
Development guide for the Momswap Geo demo app (`web/`) and TypeScript client (`libs/geo-api-client`).
|
||||
Development guide for the demo frontend in `web/` and the reusable TypeScript client in `libs/geo-api-client`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `web/` is a no-bundler Vue + Vuetify app served directly by the Go backend at `/web/`.
|
||||
- `libs/geo-api-client` contains signed auth and API request logic reused by frontend code.
|
||||
- Asset binaries are stored in S3-compatible storage, while frontend works with metadata and service-relative links returned by the API.
|
||||
|
||||
## Demo app (`web/`)
|
||||
|
||||
Vue 3 + Vuetify 3 single-page app, no bundler. Served by the backend at `/web/`.
|
||||
### File map
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
```text
|
||||
web/
|
||||
├── index.html # Entry page, Vue/Vuetify from CDN
|
||||
├── app.js # Vue app, state, handlers
|
||||
├── api.js # GeoApiClient wrapper for browser
|
||||
├── qr.js # QR code generation (pk/pb keys)
|
||||
└── scanner.js # QR scanner from camera (Import pk)
|
||||
├── index.html # Entry page, loads Vue/Vuetify from CDN
|
||||
├── app.js # Main app state and handlers
|
||||
├── api.js # GeoApiClient wrapper for browser usage
|
||||
├── qr.js # QR code generation for key sharing/backup
|
||||
└── scanner.js # Camera QR scanner for key import
|
||||
```
|
||||
|
||||
### Running locally
|
||||
### Local run
|
||||
|
||||
1. Start the API:
|
||||
1. Start backend:
|
||||
```bash
|
||||
go run ./cmd/api
|
||||
# or: docker compose up -d
|
||||
# or
|
||||
docker compose up -d
|
||||
```
|
||||
2. Open `http://localhost:8122/web/`
|
||||
2. Open:
|
||||
- `http://localhost:8122/web/`
|
||||
- `http://localhost:8122/web/leaflet-demo.html` (Leaflet map demo for 3D/image placement + sharing)
|
||||
|
||||
### Dependencies
|
||||
### Runtime dependencies
|
||||
|
||||
- Vue 3 and Vuetify 3 from CDN (no npm install in `web/`)
|
||||
- `libs/geo-api-client/dist/index.js` — built ESM client
|
||||
- `qr.js` — imports `qrcode` from esm.sh
|
||||
- `scanner.js` — imports `jsQR` from esm.sh for camera scan
|
||||
- Vue 3 and Vuetify 3 from CDN (no package manager in `web/`)
|
||||
- `libs/geo-api-client/dist/index.js` (ESM build artifact)
|
||||
- `qrcode` via `esm.sh` in `qr.js`
|
||||
- `jsQR` via `esm.sh` in `scanner.js`
|
||||
|
||||
### Build step for client
|
||||
### Supported UI flows
|
||||
|
||||
The demo app uses the pre-built client. After changing `libs/geo-api-client`:
|
||||
|
||||
```bash
|
||||
cd libs/geo-api-client
|
||||
bun run build
|
||||
```
|
||||
|
||||
With Docker, the image build runs this automatically.
|
||||
|
||||
### Features (use-cases test)
|
||||
|
||||
- Connection & Identity: API URL, key generation, pk/pb display, QR codes (pk shown by default, pb behind toggle), restore pb from pk, **Import pk from camera** (scan QR → restore pb → auto login → refresh collections), register, login
|
||||
- Collections: create, select, rename, remove
|
||||
- Features: add point (lon/lat validation -180..180, -90..90), remove, list
|
||||
- Connection and identity:
|
||||
- API URL configuration
|
||||
- key generation
|
||||
- pk/pb display and QR export
|
||||
- restore/import keypair from QR
|
||||
- register and login
|
||||
- Collection management:
|
||||
- create, select, rename, delete
|
||||
- Feature management:
|
||||
- add/list/delete points
|
||||
- lon/lat validation (`-180..180`, `-90..90`)
|
||||
- Asset-ready feature rendering:
|
||||
- read linked media from `feature.properties.assets`
|
||||
- use relative `link` value (for example `/v1/assets/{id}/download`) for fetch/open
|
||||
- Leaflet map example:
|
||||
- click map to place object coordinates
|
||||
- create feature + upload/link `gltf`/`glb`/image asset
|
||||
- copy/open share link and toggle public/private visibility
|
||||
|
||||
## TypeScript client (`libs/geo-api-client`)
|
||||
|
||||
Reusable API client with Ed25519 signing. See [TypeScript Frontend Integration](typescript-frontend-integration.md) for full API and integration flow.
|
||||
|
||||
### Build & test
|
||||
The TypeScript client centralizes auth signatures and API requests.
|
||||
|
||||
```bash
|
||||
cd libs/geo-api-client
|
||||
@@ -63,10 +72,20 @@ bun test
|
||||
bun run build
|
||||
```
|
||||
|
||||
After client changes, rebuild before loading the demo app. Docker image builds handle this automatically.
|
||||
|
||||
## Frontend implementation notes for assets
|
||||
|
||||
- Treat `properties.assets` as backend-owned metadata. Do not derive URLs in frontend from S3 config.
|
||||
- Always use backend-provided relative `link` for downloads so permission checks remain server-side.
|
||||
- When asset visibility changes (`isPublic`), refresh affected feature list to keep UI in sync.
|
||||
|
||||
## Related docs
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage, integration flow, examples |
|
||||
| [Ed25519 Security Use Cases](ed25519-security-use-cases.md) | Auth flows, registration, signatures |
|
||||
| [Geo Auth Backend Plan](geo-auth-backend-plan.md) | Architecture and planning |
|
||||
| [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage and integration flow |
|
||||
| [Assets Storage and Sharing](assets-storage-and-sharing.md) | Asset lifecycle, deduplication, visibility, API endpoints |
|
||||
| [Docker MinIO Local Development](docker-minio-local-dev.md) | Local object storage topology and verification |
|
||||
| [Ed25519 Security Use Cases](ed25519-security-use-cases.md) | Auth and signature behavior |
|
||||
| [Geo Auth Backend Plan](geo-auth-backend-plan.md) | Architecture and planning history |
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
This document explains how frontend developers should integrate with the backend through the reusable TypeScript client at `libs/geo-api-client`.
|
||||
|
||||
> **See also:** [Frontend Development](frontend-development.md) — demo app (`web/`), local dev, build steps.
|
||||
>
|
||||
> **Asset docs:** [Assets Storage and Sharing](assets-storage-and-sharing.md) and [Docker MinIO Local Development](docker-minio-local-dev.md).
|
||||
|
||||
Primary backend URL for integration:
|
||||
|
||||
@@ -76,6 +78,16 @@ Key methods:
|
||||
- [`createPointFeature(collectionId, lon, lat, properties)`](https://git.produktor.io/momswap/backend/src/branch/main/libs/geo-api-client/src/GeoApiClient.ts#L156) — Add a Point. lon ∈ [-180,180], lat ∈ [-90,90]. Returns feature id.
|
||||
- [`deleteFeature(featureId)`](https://git.produktor.io/momswap/backend/src/branch/main/libs/geo-api-client/src/GeoApiClient.ts#L172) — Delete a feature.
|
||||
|
||||
## Asset API integration note
|
||||
|
||||
Asset endpoints are currently available at backend API level (`/v1/assets...`) and can be called from frontend apps directly with authenticated `fetch` requests.
|
||||
|
||||
Current frontend contract points:
|
||||
|
||||
- Feature list responses include linked media under `feature.properties.assets`.
|
||||
- Each asset includes a backend-relative download path (`link`) like `/v1/assets/{id}/download`.
|
||||
- Frontend should use this relative path and avoid constructing direct S3 URLs.
|
||||
|
||||
## Recommended integration flow
|
||||
|
||||
1. Create one `GeoApiClient` instance per backend base URL.
|
||||
|
||||
Vendored
+20
@@ -638,6 +638,26 @@ class GeoApiClient {
|
||||
async deleteFeature(featureId) {
|
||||
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
|
||||
}
|
||||
async createOrLinkAsset(input) {
|
||||
return this.request("/v1/assets", { method: "POST", body: input });
|
||||
}
|
||||
async getAssetSignedUploadUrl(assetId, contentType) {
|
||||
return this.request(`/v1/assets/${assetId}/signed-upload`, {
|
||||
method: "POST",
|
||||
body: { contentType: contentType ?? "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
async setAssetVisibility(assetId, isPublic) {
|
||||
return this.request(`/v1/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
body: { isPublic }
|
||||
});
|
||||
}
|
||||
resolveRelativeLink(path) {
|
||||
if (!path.startsWith("/"))
|
||||
return `${this.baseUrl}/${path}`;
|
||||
return `${this.baseUrl}${path}`;
|
||||
}
|
||||
}
|
||||
export {
|
||||
signMessage,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
|
||||
import { bytesToBase64Url, textToBytes } from "./encoding";
|
||||
import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage";
|
||||
import type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||
import type { AssetKind, AssetRecord, FeatureAsset, InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||
|
||||
type RequestInitLike = Omit<RequestInit, "body"> & { body?: unknown };
|
||||
|
||||
type FeatureProperties = {
|
||||
assets?: FeatureAsset[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type GeoFeature = {
|
||||
id: string;
|
||||
geometry?: {
|
||||
coordinates?: number[];
|
||||
};
|
||||
properties?: FeatureProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* TypeScript API client for Momswap Geo backend.
|
||||
* Handles Ed25519 key storage, auth flows, and GeoJSON collection/feature CRUD.
|
||||
@@ -201,7 +214,7 @@ export class GeoApiClient {
|
||||
}
|
||||
|
||||
/** List GeoJSON features in a collection. Must own the collection. */
|
||||
async listFeatures(collectionId: string): Promise<{ features: unknown[] }> {
|
||||
async listFeatures(collectionId: string): Promise<{ features: GeoFeature[] }> {
|
||||
return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
|
||||
}
|
||||
|
||||
@@ -228,4 +241,47 @@ export class GeoApiClient {
|
||||
async deleteFeature(featureId: string): Promise<void> {
|
||||
return this.request(`/v1/features/${featureId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or reuse an asset by checksum+ext for the authenticated owner and link it to a feature.
|
||||
* If checksum/ext already exists for this owner, backend returns existing asset and refreshes link metadata.
|
||||
*/
|
||||
async createOrLinkAsset(input: {
|
||||
featureId: string;
|
||||
checksum: string;
|
||||
ext: string;
|
||||
kind: AssetKind;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}): Promise<{ asset: AssetRecord; link: string }> {
|
||||
return this.request("/v1/assets", { method: "POST", body: input });
|
||||
}
|
||||
|
||||
/** Request a signed upload URL for an existing asset. */
|
||||
async getAssetSignedUploadUrl(
|
||||
assetId: string,
|
||||
contentType?: string
|
||||
): Promise<{ url: string; method: string }> {
|
||||
return this.request(`/v1/assets/${assetId}/signed-upload`, {
|
||||
method: "POST",
|
||||
body: { contentType: contentType ?? "application/octet-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Update asset visibility (owner only). */
|
||||
async setAssetVisibility(assetId: string, isPublic: boolean): Promise<{ asset: AssetRecord; link: string }> {
|
||||
return this.request(`/v1/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
body: { isPublic },
|
||||
});
|
||||
}
|
||||
|
||||
/** Build absolute download URL from service-relative link returned in feature assets. */
|
||||
resolveRelativeLink(path: string): string {
|
||||
if (!path.startsWith("/")) return `${this.baseUrl}/${path}`;
|
||||
return `${this.baseUrl}${path}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export { GeoApiClient } from "./GeoApiClient";
|
||||
export { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
|
||||
export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage";
|
||||
export type { InvitationPayload, StorageLike, StoredKeys } from "./types";
|
||||
export type {
|
||||
AssetKind,
|
||||
AssetRecord,
|
||||
AssetVisibility,
|
||||
FeatureAsset,
|
||||
InvitationPayload,
|
||||
StorageLike,
|
||||
StoredKeys,
|
||||
} from "./types";
|
||||
|
||||
@@ -16,3 +16,34 @@ export type StorageLike = {
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
export type AssetKind = "image" | "3d";
|
||||
|
||||
export type AssetVisibility = {
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
export type AssetRecord = {
|
||||
id: string;
|
||||
ownerKey: string;
|
||||
checksum: string;
|
||||
ext: string;
|
||||
kind: AssetKind;
|
||||
mimeType?: string;
|
||||
sizeBytes: number;
|
||||
objectKey: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type FeatureAsset = {
|
||||
id: string;
|
||||
kind: AssetKind;
|
||||
name?: string;
|
||||
description?: string;
|
||||
checksum: string;
|
||||
ext: string;
|
||||
isPublic: boolean;
|
||||
link: string;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,22 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
|
||||
const sessions = new Map<string, string>();
|
||||
let collectionId = 0;
|
||||
let featureId = 0;
|
||||
let assetId = 0;
|
||||
const collectionsByUser = new Map<string, Array<{ id: string; name: string }>>();
|
||||
const featuresByCollection = new Map<string, Array<{ id: string; geometry: unknown; properties: Record<string, unknown> }>>();
|
||||
const assetsByOwner = new Map<string, Array<{
|
||||
id: string;
|
||||
ownerKey: string;
|
||||
checksum: string;
|
||||
ext: string;
|
||||
kind: "image" | "3d";
|
||||
mimeType?: string;
|
||||
sizeBytes: number;
|
||||
objectKey: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>>();
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
@@ -98,35 +114,138 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
|
||||
if (!user && path.startsWith("/v1/collections") && method !== "GET") {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!user && path.startsWith("/v1/assets")) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// POST /v1/collections
|
||||
if (method === "POST" && path === "/v1/collections") {
|
||||
const body = (await req.json()) as { name?: string };
|
||||
collectionId++;
|
||||
const c = { id: `col-${collectionId}`, name: body?.name ?? "Unnamed" };
|
||||
const list = collectionsByUser.get(user!) ?? [];
|
||||
list.push(c);
|
||||
collectionsByUser.set(user!, list);
|
||||
return Response.json(c, { status: 201 });
|
||||
}
|
||||
|
||||
// GET /v1/collections
|
||||
if (method === "GET" && path === "/v1/collections") {
|
||||
return Response.json({ collections: [] });
|
||||
return Response.json({ collections: collectionsByUser.get(user ?? "") ?? [] });
|
||||
}
|
||||
|
||||
// POST /v1/collections/:id/features
|
||||
if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
|
||||
const colId = path.split("/")[3]!;
|
||||
const owned = (collectionsByUser.get(user!) ?? []).some((c) => c.id === colId);
|
||||
if (!owned) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = (await req.json()) as { geometry?: unknown; properties?: unknown };
|
||||
featureId++;
|
||||
const f = {
|
||||
id: `feat-${featureId}`,
|
||||
geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] },
|
||||
properties: body?.properties ?? {},
|
||||
properties: (body?.properties as Record<string, unknown>) ?? {},
|
||||
};
|
||||
const list = featuresByCollection.get(colId) ?? [];
|
||||
list.push(f);
|
||||
featuresByCollection.set(colId, list);
|
||||
return Response.json(f, { status: 201 });
|
||||
}
|
||||
|
||||
// GET /v1/collections/:id/features
|
||||
if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
|
||||
return Response.json({ features: [] });
|
||||
const colId = path.split("/")[3]!;
|
||||
const features = featuresByCollection.get(colId) ?? [];
|
||||
const ownerAssets = assetsByOwner.get(user ?? "") ?? [];
|
||||
const withAssets = features.map((f) => {
|
||||
const assets = ownerAssets
|
||||
.filter((a) => (f.properties.assets as Array<{ id: string }> | undefined)?.some((x) => x.id === a.id))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
kind: a.kind,
|
||||
checksum: a.checksum,
|
||||
ext: a.ext,
|
||||
isPublic: a.isPublic,
|
||||
link: `/v1/assets/${a.id}/download`,
|
||||
}));
|
||||
return { ...f, properties: { ...f.properties, assets } };
|
||||
});
|
||||
return Response.json({ features: withAssets });
|
||||
}
|
||||
|
||||
// POST /v1/assets
|
||||
if (method === "POST" && path === "/v1/assets") {
|
||||
const body = (await req.json()) as {
|
||||
featureId?: string;
|
||||
checksum?: string;
|
||||
ext?: string;
|
||||
kind?: "image" | "3d";
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
};
|
||||
if (!body.featureId || !body.checksum || !body.ext || !body.kind) {
|
||||
return Response.json({ error: "missing fields" }, { status: 400 });
|
||||
}
|
||||
const ownerAssets = assetsByOwner.get(user!) ?? [];
|
||||
const existing = ownerAssets.find(
|
||||
(a) => a.checksum === body.checksum.toLowerCase() && a.ext === body.ext.toLowerCase()
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const asset =
|
||||
existing ??
|
||||
{
|
||||
id: `asset-${++assetId}`,
|
||||
ownerKey: user!,
|
||||
checksum: body.checksum.toLowerCase(),
|
||||
ext: body.ext.toLowerCase(),
|
||||
kind: body.kind,
|
||||
mimeType: body.mimeType,
|
||||
sizeBytes: body.sizeBytes ?? 0,
|
||||
objectKey: `${user!}/${body.checksum.toLowerCase()}.${body.ext.toLowerCase()}`,
|
||||
isPublic: body.isPublic ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (!existing) {
|
||||
ownerAssets.push(asset);
|
||||
assetsByOwner.set(user!, ownerAssets);
|
||||
}
|
||||
for (const featureList of featuresByCollection.values()) {
|
||||
for (const f of featureList) {
|
||||
if (f.id === body.featureId) {
|
||||
const oldAssets = Array.isArray(f.properties.assets) ? (f.properties.assets as Array<{ id: string }>) : [];
|
||||
if (!oldAssets.some((x) => x.id === asset.id)) {
|
||||
f.properties.assets = [...oldAssets, { id: asset.id, name: body.name, description: body.description }];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Response.json({ asset, link: `/v1/assets/${asset.id}/download` }, { status: existing ? 200 : 201 });
|
||||
}
|
||||
|
||||
// POST /v1/assets/:id/signed-upload
|
||||
if (method === "POST" && path.match(/^\/v1\/assets\/[^/]+\/signed-upload$/)) {
|
||||
const id = path.split("/")[3]!;
|
||||
return Response.json({ url: `http://upload.local/${id}`, method: "PUT" });
|
||||
}
|
||||
|
||||
// PATCH /v1/assets/:id
|
||||
if (method === "PATCH" && path.match(/^\/v1\/assets\/[^/]+$/)) {
|
||||
const id = path.split("/")[3]!;
|
||||
const body = (await req.json()) as { isPublic?: boolean };
|
||||
const ownerAssets = assetsByOwner.get(user!) ?? [];
|
||||
const asset = ownerAssets.find((a) => a.id === id);
|
||||
if (!asset) {
|
||||
return Response.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
asset.isPublic = Boolean(body.isPublic);
|
||||
asset.updatedAt = new Date().toISOString();
|
||||
return Response.json({ asset, link: `/v1/assets/${asset.id}/download` });
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
@@ -180,4 +299,41 @@ describe("GeoApiClient integration (docs flow)", () => {
|
||||
const { features } = await client.listFeatures(created.id);
|
||||
expect(Array.isArray(features)).toBe(true);
|
||||
});
|
||||
|
||||
test("asset flow: create/link -> signed upload -> toggle visibility", async () => {
|
||||
const keys = await client.ensureKeysInStorage();
|
||||
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
|
||||
await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
||||
|
||||
const created = await client.createCollection("3D");
|
||||
const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, { name: "Palm Tree Point" });
|
||||
|
||||
const createdAsset = await client.createOrLinkAsset({
|
||||
featureId: feature.id,
|
||||
checksum: "ABCDEF0123",
|
||||
ext: "glb",
|
||||
kind: "3d",
|
||||
mimeType: "model/gltf-binary",
|
||||
sizeBytes: 1024,
|
||||
name: "Palm Tree",
|
||||
description: "Low poly",
|
||||
isPublic: true,
|
||||
});
|
||||
expect(createdAsset.asset.id).toBeDefined();
|
||||
expect(createdAsset.link).toBe(`/v1/assets/${createdAsset.asset.id}/download`);
|
||||
expect(client.resolveRelativeLink(createdAsset.link)).toContain(`/v1/assets/${createdAsset.asset.id}/download`);
|
||||
|
||||
const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary");
|
||||
expect(upload.method).toBe("PUT");
|
||||
expect(upload.url).toContain(createdAsset.asset.id);
|
||||
|
||||
const toggled = await client.setAssetVisibility(createdAsset.asset.id, false);
|
||||
expect(toggled.asset.isPublic).toBe(false);
|
||||
|
||||
const listed = await client.listFeatures(created.id);
|
||||
const first = listed.features[0];
|
||||
const assets = first.properties?.assets ?? [];
|
||||
expect(assets.length).toBeGreaterThan(0);
|
||||
expect(assets[0]?.id).toBe(createdAsset.asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
+120
-1
@@ -16,9 +16,13 @@ createApp({
|
||||
selectedCollectionId: "",
|
||||
editingCollectionName: "",
|
||||
featuresByCollection: {},
|
||||
selectedFeatureId: "",
|
||||
newFeatureLon: "",
|
||||
newFeatureLat: "",
|
||||
newFeatureName: "",
|
||||
newAssetName: "",
|
||||
newAssetDescription: "",
|
||||
selectedAssetFileName: "",
|
||||
status: "Ready",
|
||||
qrPk: "",
|
||||
qrPb: "",
|
||||
@@ -28,6 +32,7 @@ createApp({
|
||||
cameraError: "",
|
||||
cameraAbortController: null,
|
||||
});
|
||||
const selectedAssetFile = ref(null);
|
||||
|
||||
let client = createApiClient(apiBase.value);
|
||||
|
||||
@@ -207,6 +212,12 @@ createApp({
|
||||
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;
|
||||
}
|
||||
@@ -259,7 +270,108 @@ createApp({
|
||||
const lon = coords?.[0] ?? "—";
|
||||
const lat = coords?.[1] ?? "—";
|
||||
const name = f.properties?.name ?? f.id ?? "—";
|
||||
return { id: f.id, name, lon, lat };
|
||||
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(
|
||||
@@ -277,6 +389,7 @@ createApp({
|
||||
|
||||
const selectCollection = (id) => {
|
||||
state.selectedCollectionId = id;
|
||||
state.selectedFeatureId = "";
|
||||
const c = state.collections.find((x) => x.id === id);
|
||||
state.editingCollectionName = c ? c.name : "";
|
||||
};
|
||||
@@ -338,6 +451,12 @@ createApp({
|
||||
removeFeature,
|
||||
formatFeature,
|
||||
featuresFor,
|
||||
selectFeature,
|
||||
selectedFeature,
|
||||
onAssetFileChange,
|
||||
createAndUploadAsset,
|
||||
toggleAssetVisibility,
|
||||
openAssetLink,
|
||||
togglePrivateQR,
|
||||
togglePublicQR,
|
||||
};
|
||||
|
||||
@@ -130,9 +130,65 @@
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Momswap Leaflet 3D Assets Demo</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0b1220;
|
||||
color: #dbe5f6;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 12px;
|
||||
height: 100vh;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.panel {
|
||||
background: #10192c;
|
||||
border: 1px solid #24344f;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
color: #9eb0ce;
|
||||
}
|
||||
input,
|
||||
button,
|
||||
select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #33496a;
|
||||
background: #0d1525;
|
||||
color: #e4ecfa;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: #9eb0ce;
|
||||
}
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: #8ee3a1;
|
||||
min-height: 18px;
|
||||
}
|
||||
#map {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #24344f;
|
||||
}
|
||||
.asset-card {
|
||||
border: 1px solid #314869;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.asset-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<div class="panel">
|
||||
<h1>Leaflet 3D Asset Sharing Demo</h1>
|
||||
<div class="muted">Click map to place an object, upload asset, then share via backend link.</div>
|
||||
|
||||
<h2>Connection</h2>
|
||||
<label for="apiBase">API Base URL</label>
|
||||
<input id="apiBase" value="http://localhost:8122" />
|
||||
<button id="applyApi">Apply API URL</button>
|
||||
|
||||
<h2>Auth</h2>
|
||||
<button id="ensureKeys">Ensure Keys</button>
|
||||
<button id="register">Register</button>
|
||||
<button id="login">Login</button>
|
||||
<div class="muted" id="publicKeyPreview"></div>
|
||||
|
||||
<h2>Collection</h2>
|
||||
<label for="collectionName">Name</label>
|
||||
<input id="collectionName" placeholder="3D objects" value="3D objects demo" />
|
||||
<button id="createCollection">Create Collection</button>
|
||||
<div class="muted">Current: <span id="collectionInfo">none</span></div>
|
||||
|
||||
<h2>Place + Upload Asset</h2>
|
||||
<div class="muted">1) Click map to choose location. 2) Select file. 3) Upload and link.</div>
|
||||
<label for="assetFile">3D/Image file</label>
|
||||
<input id="assetFile" type="file" accept=".gltf,.glb,.jpg,.jpeg,.png,.webp" />
|
||||
<label for="assetName">Asset name</label>
|
||||
<input id="assetName" placeholder="Palm Tree" />
|
||||
<label for="assetDesc">Description</label>
|
||||
<input id="assetDesc" placeholder="Low-poly palm tree" />
|
||||
<button id="uploadAsset">Create Feature + Upload + Link</button>
|
||||
|
||||
<h2>Stored Assets</h2>
|
||||
<div id="assetsList"></div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""
|
||||
></script>
|
||||
<script type="module" src="./leaflet-demo.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,274 @@
|
||||
import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
|
||||
|
||||
class BrowserStorage {
|
||||
getItem(key) {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
setItem(key, value) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
removeItem(key) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById("status");
|
||||
const apiBaseEl = document.getElementById("apiBase");
|
||||
const publicKeyPreviewEl = document.getElementById("publicKeyPreview");
|
||||
const collectionInfoEl = document.getElementById("collectionInfo");
|
||||
const collectionNameEl = document.getElementById("collectionName");
|
||||
const assetFileEl = document.getElementById("assetFile");
|
||||
const assetNameEl = document.getElementById("assetName");
|
||||
const assetDescEl = document.getElementById("assetDesc");
|
||||
const assetsListEl = document.getElementById("assetsList");
|
||||
|
||||
let client = new GeoApiClient(apiBaseEl.value.trim(), new BrowserStorage());
|
||||
let keys = null;
|
||||
let accessToken = "";
|
||||
let collectionId = "";
|
||||
let selectedLatLng = null;
|
||||
const markers = new Map();
|
||||
|
||||
const map = L.map("map").setView([28.4636, -16.2518], 10);
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap",
|
||||
}).addTo(map);
|
||||
|
||||
let pendingMarker = null;
|
||||
map.on("click", (event) => {
|
||||
selectedLatLng = event.latlng;
|
||||
if (pendingMarker) map.removeLayer(pendingMarker);
|
||||
pendingMarker = L.marker(event.latlng, { title: "Pending feature position" }).addTo(map);
|
||||
setStatus(`Selected location: ${event.latlng.lat.toFixed(5)}, ${event.latlng.lng.toFixed(5)}`);
|
||||
});
|
||||
|
||||
function setStatus(message) {
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function extFromFilename(name) {
|
||||
const idx = name.lastIndexOf(".");
|
||||
if (idx <= 0) return "";
|
||||
return name.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function kindFromExt(ext) {
|
||||
return ext === "gltf" || ext === "glb" ? "3d" : "image";
|
||||
}
|
||||
|
||||
async function sha256Hex(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("");
|
||||
}
|
||||
|
||||
function setClientBase(baseUrl) {
|
||||
const normalized = baseUrl.trim().replace(/\/+$/g, "");
|
||||
client = new GeoApiClient(normalized, new BrowserStorage());
|
||||
if (accessToken) client.setAccessToken(accessToken);
|
||||
localStorage.setItem("geo_api_base", normalized);
|
||||
setStatus(`API base updated: ${normalized}`);
|
||||
}
|
||||
|
||||
async function ensureKeys() {
|
||||
keys = await client.ensureKeysInStorage();
|
||||
publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`;
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (!keys) await ensureKeys();
|
||||
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!keys) await ensureKeys();
|
||||
accessToken = await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
||||
client.setAccessToken(accessToken);
|
||||
}
|
||||
|
||||
async function ensureCollection() {
|
||||
if (collectionId) return collectionId;
|
||||
const created = await client.createCollection(collectionNameEl.value.trim() || "3D objects demo");
|
||||
collectionId = created.id;
|
||||
collectionInfoEl.textContent = `${created.name} (${created.id})`;
|
||||
return collectionId;
|
||||
}
|
||||
|
||||
function renderAssets(features) {
|
||||
assetsListEl.innerHTML = "";
|
||||
for (const feature of features) {
|
||||
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
|
||||
for (const asset of assets) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "asset-card";
|
||||
const absoluteLink = client.resolveRelativeLink(asset.link);
|
||||
card.innerHTML = `
|
||||
<div><strong>${asset.kind}</strong> • ${asset.ext}</div>
|
||||
<div class="muted">Feature: ${feature.id}</div>
|
||||
<div class="muted">Visibility: ${asset.isPublic ? "public" : "private"}</div>
|
||||
<div class="muted">Link: ${asset.link}</div>
|
||||
`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "asset-actions";
|
||||
|
||||
const openBtn = document.createElement("button");
|
||||
openBtn.textContent = "Open";
|
||||
openBtn.onclick = () => window.open(absoluteLink, "_blank", "noopener,noreferrer");
|
||||
actions.appendChild(openBtn);
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.textContent = asset.isPublic ? "Set Private" : "Set Public";
|
||||
toggleBtn.onclick = async () => {
|
||||
try {
|
||||
await client.setAssetVisibility(asset.id, !asset.isPublic);
|
||||
await refreshFeatures();
|
||||
setStatus(`Updated visibility for ${asset.id}`);
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
};
|
||||
actions.appendChild(toggleBtn);
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.textContent = "Copy Share Link";
|
||||
copyBtn.onclick = async () => {
|
||||
await navigator.clipboard.writeText(absoluteLink);
|
||||
setStatus("Share link copied to clipboard.");
|
||||
};
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
card.appendChild(actions);
|
||||
assetsListEl.appendChild(card);
|
||||
}
|
||||
}
|
||||
if (!assetsListEl.children.length) {
|
||||
assetsListEl.innerHTML = `<div class="muted">No assets linked yet.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFeatures() {
|
||||
if (!collectionId) return;
|
||||
const { features } = await client.listFeatures(collectionId);
|
||||
for (const feature of features) {
|
||||
const coords = feature.geometry?.coordinates;
|
||||
if (!coords || coords.length < 2) continue;
|
||||
const lat = coords[1];
|
||||
const lon = coords[0];
|
||||
if (!markers.has(feature.id)) {
|
||||
const marker = L.marker([lat, lon]).addTo(map);
|
||||
marker.bindPopup(`Feature ${feature.id}`);
|
||||
markers.set(feature.id, marker);
|
||||
}
|
||||
}
|
||||
renderAssets(features);
|
||||
}
|
||||
|
||||
async function createFeatureAndUpload() {
|
||||
if (!selectedLatLng) {
|
||||
throw new Error("Click the map to choose object location first.");
|
||||
}
|
||||
const file = assetFileEl.files?.[0];
|
||||
if (!file) {
|
||||
throw new Error("Select a 3D/image file first.");
|
||||
}
|
||||
const ext = extFromFilename(file.name);
|
||||
if (!ext) {
|
||||
throw new Error("File extension is required.");
|
||||
}
|
||||
|
||||
await ensureCollection();
|
||||
const featureName = assetNameEl.value.trim() || file.name;
|
||||
const feature = await client.createPointFeature(
|
||||
collectionId,
|
||||
selectedLatLng.lng,
|
||||
selectedLatLng.lat,
|
||||
{ name: featureName, placement: "leaflet-demo" }
|
||||
);
|
||||
|
||||
const checksum = await sha256Hex(file);
|
||||
const kind = kindFromExt(ext);
|
||||
const created = await client.createOrLinkAsset({
|
||||
featureId: feature.id,
|
||||
checksum,
|
||||
ext,
|
||||
kind,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
sizeBytes: file.size,
|
||||
name: featureName,
|
||||
description: assetDescEl.value.trim(),
|
||||
isPublic: true,
|
||||
});
|
||||
const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream");
|
||||
const uploadRes = await fetch(signedUpload.url, {
|
||||
method: signedUpload.method || "PUT",
|
||||
headers: file.type ? { "Content-Type": file.type } : undefined,
|
||||
body: file,
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadRes.status}`);
|
||||
}
|
||||
|
||||
await refreshFeatures();
|
||||
assetNameEl.value = "";
|
||||
assetDescEl.value = "";
|
||||
assetFileEl.value = "";
|
||||
setStatus("3D/image object stored and linked. Share link available in Stored Assets.");
|
||||
}
|
||||
|
||||
document.getElementById("applyApi").onclick = () => {
|
||||
setClientBase(apiBaseEl.value);
|
||||
};
|
||||
|
||||
document.getElementById("ensureKeys").onclick = async () => {
|
||||
try {
|
||||
await ensureKeys();
|
||||
setStatus("Keys are ready.");
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("register").onclick = async () => {
|
||||
try {
|
||||
await register();
|
||||
setStatus("Registered.");
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("login").onclick = async () => {
|
||||
try {
|
||||
await login();
|
||||
setStatus("Logged in.");
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("createCollection").onclick = async () => {
|
||||
try {
|
||||
await ensureCollection();
|
||||
setStatus("Collection is ready.");
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("uploadAsset").onclick = async () => {
|
||||
try {
|
||||
if (!accessToken) throw new Error("Login first.");
|
||||
await createFeatureAndUpload();
|
||||
} catch (error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const savedBase = localStorage.getItem("geo_api_base");
|
||||
if (savedBase) {
|
||||
apiBaseEl.value = savedBase;
|
||||
setClientBase(savedBase);
|
||||
}
|
||||
Reference in New Issue
Block a user