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,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>
|
||||
Reference in New Issue
Block a user