Initial commit: Coffee Map PWA

Vue 3 + Vite PWA backed by ATProto PDS (coffee.apoena.dev).
Stores coffee spots as dev.apoena.coffeespot records with name,
geolocation, note, and status. Map via MapLibre + OpenFreeMap,
auth via ATProto OAuth, deploy via Docker + Nginx on Coolify.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Calixte
2026-03-28 23:01:17 +01:00
commit 645f93069c
26 changed files with 7241 additions and 0 deletions

35
src/lib/atproto.ts Normal file
View File

@@ -0,0 +1,35 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
// The client_id must equal the public URL of client-metadata.json.
// Update VITE_APP_URL in your environment or set it here directly.
const APP_URL = import.meta.env.VITE_APP_URL ?? 'https://coffee.apoena.dev'
let _client: BrowserOAuthClient | null = null
export function getOAuthClient(): BrowserOAuthClient {
if (!_client) {
_client = new BrowserOAuthClient({
clientMetadata: {
client_id: `${APP_URL}/client-metadata.json`,
client_name: 'Coffee Map',
client_uri: APP_URL,
redirect_uris: [`${APP_URL}/oauth/callback`],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: 'atproto transition:generic',
dpop_bound_access_tokens: true,
token_endpoint_auth_method: 'none',
application_type: 'web',
},
// Use the public ATProto resolver — for full privacy use your own PDS
handleResolver: 'https://bsky.social',
})
}
return _client
}
export function createAgent(session: Awaited<ReturnType<BrowserOAuthClient['restore']>>): Agent {
if (!session) throw new Error('No session')
return new Agent(session)
}

36
src/lib/geocode.ts Normal file
View File

@@ -0,0 +1,36 @@
// Nominatim (OpenStreetMap) geocoding — no API key, no Google
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
export interface GeoResult {
lat: number
lng: number
displayName: string
}
export async function geocodeAddress(query: string): Promise<GeoResult | null> {
const params = new URLSearchParams({
q: query,
format: 'json',
limit: '1',
addressdetails: '0',
})
const res = await fetch(`${NOMINATIM_URL}?${params}`, {
headers: {
// Required by Nominatim usage policy
'Accept-Language': 'en',
'User-Agent': 'CoffeeMap/0.1 (personal app)',
},
})
if (!res.ok) return null
const data = (await res.json()) as Array<{ lat: string; lon: string; display_name: string }>
if (!data.length) return null
return {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
displayName: data[0].display_name,
}
}

25
src/lib/lexicon.ts Normal file
View File

@@ -0,0 +1,25 @@
// ATProto lexicon for coffee shop records.
// NSID is based on the app domain: coffee.apoena.dev → dev.apoena.coffeespot
export const COLLECTION = 'dev.apoena.coffeespot'
export type ShopStatus = 'want' | 'visited'
export interface CoffeeSpotRecord {
$type: typeof COLLECTION
name: string
lat: number
lng: number
note?: string
status: ShopStatus
createdAt: string // ISO 8601
}
export function createCoffeeSpotRecord(
fields: Omit<CoffeeSpotRecord, '$type' | 'createdAt'>,
): CoffeeSpotRecord {
return {
$type: COLLECTION,
createdAt: new Date().toISOString(),
...fields,
}
}