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:
35
src/lib/atproto.ts
Normal file
35
src/lib/atproto.ts
Normal 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
36
src/lib/geocode.ts
Normal 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
25
src/lib/lexicon.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user