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:
2026-08-05 13:14:03 +00:00
parent cac528ba9a
commit d6ef514c84
47 changed files with 8444 additions and 2 deletions
+17
View File
@@ -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>
+2719
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+217
View File
@@ -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);
});
})();
+21
View File
@@ -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>
+35
View File
@@ -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),
};
+21
View File
@@ -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 },
);
});
}
+50
View File
@@ -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))}`;
}
+7
View File
@@ -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');
+57
View File
@@ -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;
},
},
});
+89
View File
@@ -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();
},
},
});
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #app {
height: 100%;
margin: 0;
}
+41
View File
@@ -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>
+258
View File
@@ -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: '&copy; 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>
+47
View File
@@ -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>
+13
View File
@@ -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: [],
};
+16
View File
@@ -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"]
}
+11
View File
@@ -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',
},
},
});