Scaffold Regress: Fastify+SQLite server and Vue frontend for the Ingress-style BTC Map capture game
- Server: NIP-98 nostr auth (session cookies), BTC Map sync, claim/link/field routes, physical-presence checks via geolocation distance. 31 tests passing. - Frontend: Leaflet map, team pick, claim/link UI, Archipelago identity bridge vendored (nostr-provider.js) for dashboard launch support. - Verified end-to-end against the live BTC Map API (167 real Madeira places) and via genuine NIP-98-signed HTTP requests against the built app.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Regress</title>
|
||||
<!-- No-op outside an Archipelago iframe (see nostr-provider.js's own
|
||||
window === window.top guard) — safe to always include. Provides
|
||||
window.nostr + auto sign-in via the node's selected nostr identity
|
||||
when opened inside the Archipelago shell. -->
|
||||
<script src="/nostr-provider.js" data-session-url="/api/auth/login" data-session-mode="cookie" data-me-url="/api/auth/me" data-health-url="/api/health"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2719
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "regress-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.2.0",
|
||||
"vue-tsc": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* NIP-07 Nostr Provider Shim — Archipelago
|
||||
*
|
||||
* Vendored from archy/neode-ui/public/nostr-provider.js (generalized version).
|
||||
* Provides window.nostr (NIP-07) for iframe apps launched inside the
|
||||
* Archipelago shell, bridging signing requests via postMessage to the
|
||||
* parent frame, which relays them to the Archipelago node's identity
|
||||
* manager. Auto sign-in: does NIP-98 auth against this app's own backend,
|
||||
* then reloads so the app picks up the valid session.
|
||||
*
|
||||
* Not vendored via an Archipelago manifest hook (podsteadr isn't an
|
||||
* orchestrator-managed package — see neode-ui's EXTERNAL_URLS /
|
||||
* WEB_ONLY_APP-style "external web app" registration instead), so this
|
||||
* copy won't auto-update with archy's OTA releases. Re-sync by hand from
|
||||
* archy/neode-ui/public/nostr-provider.js if that file changes.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__archipelagoNostr) return;
|
||||
window.__archipelagoNostr = true;
|
||||
if (window === window.top) return;
|
||||
|
||||
var pending = {}, nextId = 1;
|
||||
|
||||
function request(method, params) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var id = nextId++;
|
||||
pending[id] = { resolve: resolve, reject: reject };
|
||||
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
|
||||
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (e) {
|
||||
if (!e.data || e.data.type !== 'nostr-response') return;
|
||||
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
|
||||
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
|
||||
});
|
||||
|
||||
window.nostr = {
|
||||
getPublicKey: function () { return request('getPublicKey'); },
|
||||
signEvent: function (ev) { return request('signEvent', { event: ev }); },
|
||||
sign: function (ev) { return request('signEvent', { event: ev }); },
|
||||
getRelays: function () { return request('getRelays'); },
|
||||
nip04: {
|
||||
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||
},
|
||||
nip44: {
|
||||
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
|
||||
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
|
||||
},
|
||||
};
|
||||
|
||||
// --- Loading Overlay ---
|
||||
var overlay = null;
|
||||
|
||||
function showLoader(message) {
|
||||
if (overlay) return;
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'archipelago-auth-overlay';
|
||||
overlay.innerHTML =
|
||||
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
|
||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
|
||||
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
|
||||
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
|
||||
'</svg>' +
|
||||
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
|
||||
'</div>';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(style);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function updateLoader(message) {
|
||||
if (!overlay) return;
|
||||
var txt = overlay.querySelector('div > div');
|
||||
if (txt) txt.textContent = message;
|
||||
}
|
||||
|
||||
function hideLoader() {
|
||||
if (overlay) { overlay.remove(); overlay = null; }
|
||||
}
|
||||
|
||||
// --- Per-app config (data-* attrs on the injected <script> tag). Defaults
|
||||
// match indeedhub's original hardcoded values, so apps that don't set any
|
||||
// overrides keep behaving exactly as before.
|
||||
var scriptEl = document.currentScript;
|
||||
var ds = (scriptEl && scriptEl.dataset) || {};
|
||||
var cfg = {
|
||||
healthUrl: ds.healthUrl || '/api/nostr-auth/health',
|
||||
sessionUrl: ds.sessionUrl || '/api/auth/nostr/session',
|
||||
sessionMethod: ds.sessionMethod || 'POST',
|
||||
// 'token' (default): login response is JSON {accessToken, refreshToken};
|
||||
// stored in sessionStorage, matches indeedhub.
|
||||
// 'cookie': server sets the session cookie directly on the login
|
||||
// response (Set-Cookie) — nothing to store client-side, just reload.
|
||||
sessionMode: ds.sessionMode || 'token',
|
||||
// Optional: for cookie-mode apps, check this endpoint first and skip
|
||||
// the NIP-98 handshake entirely if it reports already-authenticated
|
||||
// (401 otherwise) — avoids re-running sign-in on every iframe reload.
|
||||
meUrl: ds.meUrl || null,
|
||||
};
|
||||
|
||||
// --- Direct NIP-98 Auth ---
|
||||
var authDone = false;
|
||||
|
||||
function performNip98Auth(pubkey) {
|
||||
var healthUrl = window.location.origin + cfg.healthUrl;
|
||||
var sessionUrl = window.location.origin + cfg.sessionUrl;
|
||||
|
||||
// 1. Check if API backend is reachable (3s timeout)
|
||||
var hc = new AbortController();
|
||||
var ht = setTimeout(function () { hc.abort(); }, 3000);
|
||||
|
||||
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
|
||||
clearTimeout(ht);
|
||||
if (!r.ok) throw new Error('Health ' + r.status);
|
||||
|
||||
// 2. API is up — show loader and do NIP-98
|
||||
showLoader('Signing in with Nostr...');
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var event = {
|
||||
kind: 27235, created_at: now, content: '', pubkey: pubkey,
|
||||
tags: [['u', sessionUrl], ['method', cfg.sessionMethod]]
|
||||
};
|
||||
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
|
||||
return window.nostr.signEvent(event);
|
||||
|
||||
}).then(function (signed) {
|
||||
updateLoader('Creating session...');
|
||||
var ac = new AbortController();
|
||||
setTimeout(function () { ac.abort(); }, 10000);
|
||||
return fetch(sessionUrl, {
|
||||
method: cfg.sessionMethod,
|
||||
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
|
||||
signal: ac.signal
|
||||
});
|
||||
|
||||
}).then(function (res) {
|
||||
console.log('[nostr-provider] NIP-98: response', res.status);
|
||||
if (!res.ok) throw new Error('Auth failed: ' + res.status);
|
||||
if (cfg.sessionMode === 'cookie') {
|
||||
// Session cookie already landed via Set-Cookie on this response.
|
||||
updateLoader('Signed in! Loading...');
|
||||
console.log('[nostr-provider] NIP-98: success (cookie session), reloading...');
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
|
||||
}).then(function (data) {
|
||||
if (!data) return; // cookie-mode: handled above, nothing left to do
|
||||
if (data.accessToken) {
|
||||
sessionStorage.setItem('nostr_token', data.accessToken);
|
||||
sessionStorage.setItem('nostr_pubkey', pubkey);
|
||||
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
|
||||
updateLoader('Signed in! Loading...');
|
||||
console.log('[nostr-provider] NIP-98: success, reloading...');
|
||||
setTimeout(function () { window.location.reload(); }, 400);
|
||||
} else {
|
||||
hideLoader(); authDone = false;
|
||||
}
|
||||
|
||||
}).catch(function (err) {
|
||||
hideLoader(); authDone = false;
|
||||
var msg = err.message || String(err);
|
||||
if (msg.indexOf('abort') > -1) msg = 'API timeout';
|
||||
console.warn('[nostr-provider] NIP-98 skipped:', msg);
|
||||
});
|
||||
}
|
||||
|
||||
function doNip98Auth(pubkey) {
|
||||
if (authDone) return;
|
||||
authDone = true;
|
||||
|
||||
if (cfg.meUrl) {
|
||||
// Already-authenticated check first — avoids re-running the NIP-98
|
||||
// handshake (and its reload) on every iframe load for cookie-session
|
||||
// apps, where there's no client-visible token to check locally.
|
||||
fetch(window.location.origin + cfg.meUrl, { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
console.log('[nostr-provider] Already authenticated (meUrl ok), skipping NIP-98');
|
||||
authDone = false;
|
||||
return;
|
||||
}
|
||||
performNip98Auth(pubkey);
|
||||
})
|
||||
.catch(function () { performNip98Auth(pubkey); });
|
||||
return;
|
||||
}
|
||||
|
||||
performNip98Auth(pubkey);
|
||||
}
|
||||
|
||||
// Listen for identity from parent Archipelago frame
|
||||
window.addEventListener('message', function (e) {
|
||||
if (!e.data || e.data.type !== 'archipelago:identity') return;
|
||||
var pk = e.data.nostr_pubkey;
|
||||
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
|
||||
if (!pk) return;
|
||||
|
||||
// Skip if already signed in with a real token (not mock)
|
||||
try {
|
||||
var token = sessionStorage.getItem('nostr_token');
|
||||
if (token && token.indexOf('mock-') === -1) {
|
||||
console.log('[nostr-provider] Already signed in with real token');
|
||||
return;
|
||||
}
|
||||
} catch (x) {}
|
||||
|
||||
setTimeout(function () { doNip98Auth(pk); }, 1500);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import LoginView from './views/LoginView.vue';
|
||||
import TeamPickView from './views/TeamPickView.vue';
|
||||
import MapView from './views/MapView.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
onMounted(() => auth.ensureLoaded());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full bg-neutral-950 text-white">
|
||||
<div v-if="!auth.loaded" class="flex h-full items-center justify-center text-neutral-400">
|
||||
Loading…
|
||||
</div>
|
||||
<LoginView v-else-if="!auth.pubkey" />
|
||||
<TeamPickView v-else-if="!auth.team" />
|
||||
<MapView v-else />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Thin fetch wrapper for the Regress API (session cookie auth).
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
message = ((await res.json()) as { error?: string }).error ?? message;
|
||||
} catch {
|
||||
/* keep statusText */
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
request<T>('POST', path, body, headers),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// Browser geolocation — required for claiming/linking (physical presence, Ingress-style).
|
||||
|
||||
export interface Position {
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
export function getCurrentPosition(): Promise<Position> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation is not available in this browser'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude, accuracy: pos.coords.accuracy }),
|
||||
(err) => reject(new Error(`Could not get your location: ${err.message}`)),
|
||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// NIP-07 browser extension bridge + NIP-98 header construction.
|
||||
// Identical contract to podsteadr's — also satisfied by nostr-provider.js's
|
||||
// window.nostr shim when running inside the Archipelago dashboard.
|
||||
|
||||
export interface UnsignedEvent {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
export interface SignedEvent extends UnsignedEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
interface Nip07Provider {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: Nip07Provider;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasNip07(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.nostr;
|
||||
}
|
||||
|
||||
export function nip07(): Nip07Provider {
|
||||
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
|
||||
return window.nostr;
|
||||
}
|
||||
|
||||
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
|
||||
export async function buildNip98Header(url: string, method: string): Promise<string> {
|
||||
const event = await nip07().signEvent({
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method],
|
||||
],
|
||||
});
|
||||
return `Nostr ${btoa(JSON.stringify(event))}`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app');
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { buildNip98Header, hasNip07 } from '../lib/nip07';
|
||||
|
||||
export type Team = 'orange' | 'green';
|
||||
|
||||
interface Me {
|
||||
pubkey: string;
|
||||
displayName: string | null;
|
||||
team: Team | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
pubkey: null as string | null,
|
||||
displayName: null as string | null,
|
||||
team: null as Team | null,
|
||||
loaded: false,
|
||||
}),
|
||||
actions: {
|
||||
async ensureLoaded() {
|
||||
if (this.loaded) return;
|
||||
try {
|
||||
const me = await api.get<Me>('/api/auth/me');
|
||||
this.pubkey = me.pubkey;
|
||||
this.displayName = me.displayName;
|
||||
this.team = me.team;
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401)) throw err;
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
},
|
||||
async login() {
|
||||
if (!hasNip07()) throw new Error('No nostr extension found — install Alby or nos2x first.');
|
||||
const url = `${location.origin}/api/auth/login`;
|
||||
const header = await buildNip98Header(url, 'POST');
|
||||
const res = await api.post<{ pubkey: string; team: Team | null }>(
|
||||
'/api/auth/login',
|
||||
undefined,
|
||||
{ authorization: header },
|
||||
);
|
||||
this.pubkey = res.pubkey;
|
||||
this.team = res.team;
|
||||
},
|
||||
async logout() {
|
||||
await api.post('/api/auth/logout');
|
||||
this.pubkey = null;
|
||||
this.displayName = null;
|
||||
this.team = null;
|
||||
},
|
||||
async chooseTeam(team: Team) {
|
||||
const res = await api.post<{ team: Team }>('/api/auth/team', { team });
|
||||
this.team = res.team;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { api } from '../lib/api';
|
||||
import { getCurrentPosition } from '../lib/geo';
|
||||
import type { Team } from './auth';
|
||||
|
||||
export interface Place {
|
||||
id: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
address: string | null;
|
||||
osm_id: string | null;
|
||||
claim_team: Team | null;
|
||||
claimed_by_pubkey: string | null;
|
||||
claimed_at: number | null;
|
||||
}
|
||||
|
||||
export interface LinkRow {
|
||||
id: number;
|
||||
from_place_id: number;
|
||||
to_place_id: number;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
team: Team;
|
||||
placeIds: [number, number, number];
|
||||
areaKm2: number;
|
||||
}
|
||||
|
||||
export interface TeamScore {
|
||||
team: Team;
|
||||
claimedPlaces: number;
|
||||
links: number;
|
||||
fields: number;
|
||||
areaKm2: number;
|
||||
}
|
||||
|
||||
export const useGameStore = defineStore('game', {
|
||||
state: () => ({
|
||||
places: [] as Place[],
|
||||
links: [] as LinkRow[],
|
||||
fields: [] as Field[],
|
||||
scores: [] as TeamScore[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async refreshAll() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const [places, links, fields, scores] = await Promise.all([
|
||||
api.get<Place[]>('/api/places'),
|
||||
api.get<LinkRow[]>('/api/links'),
|
||||
api.get<Field[]>('/api/fields'),
|
||||
api.get<TeamScore[]>('/api/score'),
|
||||
]);
|
||||
this.places = places;
|
||||
this.links = links;
|
||||
this.fields = fields;
|
||||
this.scores = scores;
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Sync places from BTC Map — one-time/periodic bootstrap of the local place cache. */
|
||||
async syncPlaces() {
|
||||
await api.post('/api/sync');
|
||||
await this.refreshAll();
|
||||
},
|
||||
|
||||
async claim(placeId: number) {
|
||||
const pos = await getCurrentPosition();
|
||||
await api.post(`/api/places/${placeId}/claim`, { lat: pos.lat, lon: pos.lon });
|
||||
await this.refreshAll();
|
||||
},
|
||||
|
||||
async link(fromPlaceId: number, toPlaceId: number) {
|
||||
const pos = await getCurrentPosition();
|
||||
await api.post('/api/links', { fromPlaceId, toPlaceId, lat: pos.lat, lon: pos.lon });
|
||||
await this.refreshAll();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { hasNip07 } from '../lib/nip07';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const error = ref<string | null>(null);
|
||||
const loggingIn = ref(false);
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = null;
|
||||
loggingIn.value = true;
|
||||
try {
|
||||
await auth.login();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<h1 class="text-3xl font-bold">Regress</h1>
|
||||
<p class="max-w-sm text-neutral-400">
|
||||
Claim real Bitcoin-accepting businesses for your team. Log in with Nostr to start.
|
||||
</p>
|
||||
<button
|
||||
class="rounded-lg bg-white px-6 py-3 font-semibold text-black transition hover:bg-neutral-200 disabled:opacity-50"
|
||||
:disabled="loggingIn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loggingIn ? 'Signing in…' : 'Log in with Nostr' }}
|
||||
</button>
|
||||
<p v-if="!hasNip07()" class="text-sm text-neutral-500">
|
||||
No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard.
|
||||
</p>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import L from 'leaflet';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useGameStore } from '../stores/game';
|
||||
import type { Place } from '../stores/game';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const game = useGameStore();
|
||||
|
||||
const mapEl = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let markersLayer: L.LayerGroup | null = null;
|
||||
let linksLayer: L.LayerGroup | null = null;
|
||||
let fieldsLayer: L.LayerGroup | null = null;
|
||||
|
||||
const selectedPlaceId = ref<number | null>(null);
|
||||
const linkSourceId = ref<number | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const actionBusy = ref(false);
|
||||
|
||||
const selectedPlace = computed<Place | null>(
|
||||
() => game.places.find((p) => p.id === selectedPlaceId.value) ?? null,
|
||||
);
|
||||
const linkSourcePlace = computed<Place | null>(
|
||||
() => game.places.find((p) => p.id === linkSourceId.value) ?? null,
|
||||
);
|
||||
|
||||
const TEAM_COLOR: Record<string, string> = { orange: '#f97316', green: '#22c55e' };
|
||||
const NEUTRAL_COLOR = '#9ca3af';
|
||||
|
||||
function colorFor(team: string | null): string {
|
||||
return team ? TEAM_COLOR[team] : NEUTRAL_COLOR;
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (!map || !markersLayer || !linksLayer || !fieldsLayer) return;
|
||||
markersLayer.clearLayers();
|
||||
linksLayer.clearLayers();
|
||||
fieldsLayer.clearLayers();
|
||||
|
||||
const coordsById = new Map(game.places.map((p) => [p.id, p]));
|
||||
|
||||
for (const place of game.places) {
|
||||
const isSource = place.id === linkSourceId.value;
|
||||
const marker = L.circleMarker([place.lat, place.lon], {
|
||||
radius: isSource ? 10 : 7,
|
||||
color: isSource ? '#ffffff' : colorFor(place.claim_team),
|
||||
weight: isSource ? 3 : 2,
|
||||
fillColor: colorFor(place.claim_team),
|
||||
fillOpacity: 0.85,
|
||||
});
|
||||
marker.on('click', () => onMarkerClick(place));
|
||||
marker.bindTooltip(place.name || `Place ${place.id}`, { direction: 'top' });
|
||||
markersLayer.addLayer(marker);
|
||||
}
|
||||
|
||||
for (const link of game.links) {
|
||||
const from = coordsById.get(link.from_place_id);
|
||||
const to = coordsById.get(link.to_place_id);
|
||||
if (!from || !to) continue;
|
||||
linksLayer.addLayer(
|
||||
L.polyline(
|
||||
[
|
||||
[from.lat, from.lon],
|
||||
[to.lat, to.lon],
|
||||
],
|
||||
{ color: TEAM_COLOR[link.team], weight: 2, opacity: 0.8 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of game.fields) {
|
||||
const points = field.placeIds
|
||||
.map((id) => coordsById.get(id))
|
||||
.filter((p): p is Place => !!p)
|
||||
.map((p) => [p.lat, p.lon] as [number, number]);
|
||||
if (points.length !== 3) continue;
|
||||
fieldsLayer.addLayer(
|
||||
L.polygon(points, { color: TEAM_COLOR[field.team], weight: 1, fillOpacity: 0.15, stroke: false }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onMarkerClick(place: Place) {
|
||||
selectedPlaceId.value = place.id;
|
||||
actionError.value = null;
|
||||
}
|
||||
|
||||
async function doClaim(place: Place) {
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.claim(place.id);
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startLink(place: Place) {
|
||||
linkSourceId.value = place.id;
|
||||
actionError.value = null;
|
||||
}
|
||||
|
||||
function cancelLink() {
|
||||
linkSourceId.value = null;
|
||||
}
|
||||
|
||||
async function completeLink(place: Place) {
|
||||
if (!linkSourceId.value) return;
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.link(linkSourceId.value, place.id);
|
||||
linkSourceId.value = null;
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync() {
|
||||
actionError.value = null;
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await game.syncPlaces();
|
||||
} catch (err) {
|
||||
actionError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const orangeScore = computed(() => game.scores.find((s) => s.team === 'orange'));
|
||||
const greenScore = computed(() => game.scores.find((s) => s.team === 'green'));
|
||||
|
||||
onMounted(async () => {
|
||||
if (mapEl.value) {
|
||||
map = L.map(mapEl.value).setView([32.7607, -16.9595], 12);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
fieldsLayer = L.layerGroup().addTo(map);
|
||||
linksLayer = L.layerGroup().addTo(map);
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
}
|
||||
await game.refreshAll();
|
||||
redraw();
|
||||
});
|
||||
|
||||
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
|
||||
watch(linkSourceId, redraw);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<header class="flex items-center justify-between gap-4 bg-neutral-900 px-4 py-2 text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-bold">Regress</span>
|
||||
<span class="flex items-center gap-1 text-orange-500">
|
||||
● {{ orangeScore?.claimedPlaces ?? 0 }} places · {{ orangeScore?.fields ?? 0 }} fields ·
|
||||
{{ (orangeScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-green-500">
|
||||
● {{ greenScore?.claimedPlaces ?? 0 }} places · {{ greenScore?.fields ?? 0 }} fields ·
|
||||
{{ (greenScore?.areaKm2 ?? 0).toFixed(2) }} km²
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-neutral-400">
|
||||
{{ auth.displayName ?? auth.pubkey?.slice(0, 8) }} ·
|
||||
<span :class="auth.team === 'orange' ? 'text-orange-500' : 'text-green-500'">{{ auth.team }}</span>
|
||||
</span>
|
||||
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" :disabled="actionBusy" @click="doSync">
|
||||
Sync from BTC Map
|
||||
</button>
|
||||
<button class="rounded bg-neutral-800 px-3 py-1 hover:bg-neutral-700" @click="auth.logout()">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="relative flex-1">
|
||||
<div ref="mapEl" class="h-full w-full"></div>
|
||||
|
||||
<div
|
||||
v-if="linkSourcePlace"
|
||||
class="absolute left-4 top-4 z-[1000] rounded-lg bg-neutral-900/95 px-4 py-2 text-sm shadow-lg"
|
||||
>
|
||||
Linking from <strong>{{ linkSourcePlace.name }}</strong> — click a friendly place to complete, or
|
||||
<button class="ml-2 text-neutral-400 underline" @click="cancelLink">cancel</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedPlace"
|
||||
class="absolute right-4 top-4 z-[1000] w-72 rounded-lg bg-neutral-900/95 p-4 text-sm shadow-lg"
|
||||
>
|
||||
<div class="mb-1 flex items-start justify-between gap-2">
|
||||
<h2 class="font-bold">{{ selectedPlace.name || `Place ${selectedPlace.id}` }}</h2>
|
||||
<button class="text-neutral-500" @click="selectedPlaceId = null">✕</button>
|
||||
</div>
|
||||
<p v-if="selectedPlace.address" class="mb-2 text-neutral-400">{{ selectedPlace.address }}</p>
|
||||
<p class="mb-3">
|
||||
Status:
|
||||
<span v-if="!selectedPlace.claim_team" class="text-neutral-400">unclaimed</span>
|
||||
<span v-else :class="selectedPlace.claim_team === 'orange' ? 'text-orange-500' : 'text-green-500'">
|
||||
claimed by {{ selectedPlace.claim_team }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-if="selectedPlace.claim_team !== auth.team"
|
||||
class="rounded bg-white px-3 py-2 font-semibold text-black hover:bg-neutral-200 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="doClaim(selectedPlace)"
|
||||
>
|
||||
Claim for {{ auth.team }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="linkSourceId !== selectedPlace.id"
|
||||
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="startLink(selectedPlace)"
|
||||
>
|
||||
Link from here
|
||||
</button>
|
||||
<button
|
||||
v-if="linkSourceId && linkSourceId !== selectedPlace.id && selectedPlace.claim_team === auth.team"
|
||||
class="rounded bg-neutral-800 px-3 py-2 hover:bg-neutral-700 disabled:opacity-50"
|
||||
:disabled="actionBusy"
|
||||
@click="completeLink(selectedPlace)"
|
||||
>
|
||||
Complete link to here
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="actionError" class="mt-3 text-red-400">{{ actionError }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!game.places.length && !game.loading"
|
||||
class="absolute inset-0 z-[999] flex items-center justify-center bg-black/60"
|
||||
>
|
||||
<div class="rounded-lg bg-neutral-900 p-6 text-center">
|
||||
<p class="mb-3">No places loaded yet — pull the current Madeira dataset from BTC Map.</p>
|
||||
<button class="rounded bg-white px-4 py-2 font-semibold text-black" :disabled="actionBusy" @click="doSync">
|
||||
Sync from BTC Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import type { Team } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const error = ref<string | null>(null);
|
||||
const picking = ref(false);
|
||||
|
||||
async function pick(team: Team) {
|
||||
error.value = null;
|
||||
picking.value = true;
|
||||
try {
|
||||
await auth.chooseTeam(team);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
picking.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
<h1 class="text-2xl font-bold">Pick your faction</h1>
|
||||
<p class="max-w-sm text-neutral-400">
|
||||
This choice is permanent — you can't switch teams later, so pick the side you're actually playing for.
|
||||
</p>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
class="rounded-lg bg-orange-500 px-8 py-4 text-lg font-bold text-black transition hover:bg-orange-400 disabled:opacity-50"
|
||||
:disabled="picking"
|
||||
@click="pick('orange')"
|
||||
>
|
||||
Orange
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-green-500 px-8 py-4 text-lg font-bold text-black transition hover:bg-green-400 disabled:opacity-50"
|
||||
:disabled="picking"
|
||||
@click="pick('green')"
|
||||
>
|
||||
Green
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
orange: { team: '#f97316' },
|
||||
green: { team: '#22c55e' },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8096',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user