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

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Public URL where the app is deployed (used for ATProto OAuth client_id)
# Must match client_id and redirect_uri in public/client-metadata.json
VITE_APP_URL=https://coffee.apoena.dev

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.local
*.local

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Build stage
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
# Pass your deployed URL as a build arg
ARG VITE_APP_URL
ENV VITE_APP_URL=$VITE_APP_URL
RUN pnpm build
# Serve stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#6f4e37" />
<title>Coffee Map</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

31
nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Serve client-metadata.json with correct Content-Type
location = /client-metadata.json {
add_header Content-Type application/json;
add_header Access-Control-Allow-Origin *;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|svg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# No cache for service worker
location = /sw.js {
add_header Cache-Control "no-store";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "coffee",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.6.5",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@atproto/api": "^0.13.27",
"@atproto/oauth-client-browser": "^0.3.17",
"idb-keyval": "^6.2.1",
"maplibre-gl": "^4.7.1",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@unocss/reset": "^0.64.1",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.6.3",
"unocss": "^0.64.1",
"vite": "^6.0.5",
"vite-plugin-pwa": "^0.21.1",
"vue-tsc": "^2.2.0"
}
}

6002
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
{
"client_id": "https://coffee.apoena.dev/client-metadata.json",
"client_name": "Coffee Map",
"client_uri": "https://coffee.apoena.dev",
"redirect_uris": ["https://coffee.apoena.dev/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"
}

26
src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useShopsStore } from '@/stores/shops'
const router = useRouter()
const auth = useAuthStore()
const shops = useShopsStore()
onMounted(async () => {
// Restore session from IndexedDB on every page load (except OAuth callback,
// which handles init itself)
if (router.currentRoute.value.path !== '/oauth/callback') {
await auth.init()
if (auth.isLoggedIn) {
await shops.loadFromCache()
shops.fetchAll() // refresh in background
}
}
})
</script>

View File

@@ -0,0 +1,246 @@
<template>
<div class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 p-4" @click.self="emit('close')">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-coffee-900">Add coffee shop</h2>
<button @click="emit('close')" class="text-coffee-400 hover:text-coffee-700 text-2xl leading-none">×</button>
</div>
<!-- Import tab -->
<div class="flex gap-1 mb-4 bg-coffee-50 rounded-lg p-1">
<button
@click="mode = 'manual'"
class="flex-1 text-sm py-1.5 rounded-md transition-colors font-medium"
:class="mode === 'manual' ? 'bg-white shadow text-coffee-800' : 'text-coffee-500'"
>
Manual
</button>
<button
@click="mode = 'import'"
class="flex-1 text-sm py-1.5 rounded-md transition-colors font-medium"
:class="mode === 'import' ? 'bg-white shadow text-coffee-800' : 'text-coffee-500'"
>
Import (Google Takeout)
</button>
</div>
<!-- Manual form -->
<form v-if="mode === 'manual'" @submit.prevent="submit" class="space-y-3">
<div>
<label class="block text-sm font-medium text-coffee-800 mb-1">Name *</label>
<input
v-model="form.name"
type="text"
required
placeholder="Café de Flore"
class="w-full px-3 py-2 border border-coffee-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-coffee-400"
/>
</div>
<div>
<label class="block text-sm font-medium text-coffee-800 mb-1">Address (for geocoding)</label>
<div class="flex gap-2">
<input
v-model="form.address"
type="text"
placeholder="172 Bd Saint-Germain, Paris"
class="flex-1 px-3 py-2 border border-coffee-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-coffee-400"
/>
<button
type="button"
@click="geocode"
:disabled="!form.address || geocoding"
class="text-sm bg-coffee-100 hover:bg-coffee-200 disabled:opacity-50 text-coffee-800 px-3 py-2 rounded-lg transition-colors"
>
{{ geocoding ? '…' : '📍' }}
</button>
</div>
<p v-if="geocodeResult" class="text-xs text-green-600 mt-1"> {{ geocodeResult }}</p>
<p v-if="geocodeError" class="text-xs text-red-500 mt-1">{{ geocodeError }}</p>
</div>
<div>
<label class="block text-sm font-medium text-coffee-800 mb-1">Status</label>
<select
v-model="form.status"
class="w-full px-3 py-2 border border-coffee-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-coffee-400"
>
<option value="want"> Want to go</option>
<option value="visited"> Visited</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-coffee-800 mb-1">Note</label>
<input
v-model="form.note"
type="text"
placeholder="Recommended by…"
class="w-full px-3 py-2 border border-coffee-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-coffee-400"
/>
</div>
<p v-if="submitError" class="text-sm text-red-600">{{ submitError }}</p>
<button
type="submit"
:disabled="saving"
class="w-full bg-coffee-700 hover:bg-coffee-800 disabled:opacity-60 text-white font-medium py-2.5 rounded-lg transition-colors"
>
{{ saving ? 'Saving…' : 'Add shop' }}
</button>
</form>
<!-- Import form -->
<div v-else class="space-y-3">
<p class="text-sm text-coffee-600">
Export your Google Maps list via
<strong>Google Takeout Maps (your places)</strong>.
Upload the <code class="bg-coffee-100 px-1 rounded">Saved Places.json</code> file.
</p>
<input
type="file"
accept=".json"
@change="handleFile"
class="text-sm text-coffee-700 file:mr-3 file:bg-coffee-100 file:hover:bg-coffee-200 file:text-coffee-800 file:border-0 file:rounded-lg file:px-3 file:py-1.5 file:cursor-pointer"
/>
<p v-if="importPreview" class="text-sm text-coffee-700">
Found <strong>{{ importPreview }}</strong> coffee shops. Ready to import.
</p>
<p v-if="importError" class="text-sm text-red-600">{{ importError }}</p>
<button
v-if="pendingImport.length"
@click="runImport"
:disabled="saving"
class="w-full bg-coffee-700 hover:bg-coffee-800 disabled:opacity-60 text-white font-medium py-2.5 rounded-lg transition-colors"
>
{{ saving ? `Importing… (${importProgress}/${pendingImport.length})` : `Import ${pendingImport.length} shops` }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useShopsStore } from '@/stores/shops'
import { geocodeAddress } from '@/lib/geocode'
import type { CoffeeSpotRecord, ShopStatus } from '@/lib/lexicon'
const emit = defineEmits<{ close: [] }>()
const shopsStore = useShopsStore()
const mode = ref<'manual' | 'import'>('manual')
// Manual form
const form = reactive({
name: '',
address: '',
status: 'want' as ShopStatus,
note: '',
lat: undefined as number | undefined,
lng: undefined as number | undefined,
})
const geocoding = ref(false)
const geocodeResult = ref('')
const geocodeError = ref('')
const saving = ref(false)
const submitError = ref('')
async function geocode() {
geocoding.value = true
geocodeResult.value = ''
geocodeError.value = ''
try {
const res = await geocodeAddress(form.address)
if (res) {
form.lat = res.lat
form.lng = res.lng
geocodeResult.value = res.displayName.split(',').slice(0, 3).join(',')
} else {
geocodeError.value = 'Address not found — try a more specific query'
}
} catch {
geocodeError.value = 'Geocoding failed'
} finally {
geocoding.value = false
}
}
async function submit() {
saving.value = true
submitError.value = ''
try {
if (form.lat == null || form.lng == null) {
submitError.value = 'Please geocode the address first (tap 📍)'
saving.value = false
return
}
await shopsStore.addShop({
name: form.name,
lat: form.lat,
lng: form.lng,
status: form.status,
note: form.note || undefined,
})
emit('close')
} catch (e) {
submitError.value = e instanceof Error ? e.message : 'Failed to save'
} finally {
saving.value = false
}
}
// Import from Google Takeout
type GoogleTakeoutFeature = {
properties: {
Title: string
Location?: { Address?: string; Geo?: { type: string; coordinates: [number, number] } }
}
}
const pendingImport = ref<Omit<CoffeeSpotRecord, '$type' | 'createdAt'>[]>([])
const importPreview = ref('')
const importError = ref('')
const importProgress = ref(0)
function handleFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
importError.value = ''
importPreview.value = ''
pendingImport.value = []
const reader = new FileReader()
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target?.result as string)
const features: GoogleTakeoutFeature[] = data.features ?? []
pendingImport.value = features.map((f) => {
const geo = f.properties.Location?.Geo
return {
name: f.properties.Title,
lat: geo?.coordinates[1] ?? 0,
lng: geo?.coordinates[0] ?? 0,
status: 'want' as ShopStatus,
}
}).filter((s) => s.name && s.lat !== 0 && s.lng !== 0)
importPreview.value = `${pendingImport.value.length}`
} catch {
importError.value = 'Could not parse file — make sure it\'s a valid Google Takeout JSON'
}
}
reader.readAsText(file)
}
async function runImport() {
saving.value = true
importProgress.value = 0
try {
for (const shop of pendingImport.value) {
await shopsStore.addShop(shop)
importProgress.value++
}
emit('close')
} catch (e) {
importError.value = e instanceof Error ? e.message : 'Import failed'
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="overflow-y-auto h-full">
<div v-if="shops.length === 0" class="flex flex-col items-center justify-center h-full text-coffee-400 gap-2">
<span class="text-4xl"></span>
<p class="text-sm">No coffee shops yet</p>
</div>
<ul v-else class="divide-y divide-coffee-100">
<li
v-for="shop in shops"
:key="shop.uri"
class="px-4 py-3 hover:bg-coffee-50 cursor-pointer transition-colors"
@click="emit('select', shop)"
>
<ShopCard :shop="shop" />
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import type { Shop } from '@/stores/shops'
import ShopCard from './ShopCard.vue'
defineProps<{ shops: Shop[] }>()
const emit = defineEmits<{ select: [shop: Shop] }>()
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div ref="mapEl" class="w-full h-full" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import type { Shop } from '@/stores/shops'
const props = defineProps<{ shops: Shop[] }>()
const emit = defineEmits<{ select: [shop: Shop] }>()
const mapEl = ref<HTMLElement | null>(null)
let map: maplibregl.Map | null = null
const markers: maplibregl.Marker[] = []
onMounted(() => {
if (!mapEl.value) return
map = new maplibregl.Map({
container: mapEl.value,
// OpenFreeMap — free, no API key, Google-free
style: 'https://tiles.openfreemap.org/styles/liberty',
center: [2.3522, 48.8566], // Paris
zoom: 13,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: true }), 'top-right')
map.on('load', () => renderMarkers())
})
onUnmounted(() => {
markers.forEach((m) => m.remove())
map?.remove()
})
watch(() => props.shops, renderMarkers, { deep: true })
function renderMarkers() {
if (!map) return
markers.forEach((m) => m.remove())
markers.length = 0
for (const shop of props.shops) {
if (shop.record.lat == null || shop.record.lng == null) continue
const el = document.createElement('div')
el.className = 'coffee-marker'
el.textContent = shop.record.status === 'visited' ? '✅' : '☕'
el.title = shop.record.name
el.style.cssText = 'cursor:pointer;font-size:1.5rem;filter:drop-shadow(0 1px 2px rgba(0,0,0,.4))'
const marker = new maplibregl.Marker({ element: el })
.setLngLat([shop.record.lng, shop.record.lat])
.addTo(map!)
el.addEventListener('click', () => emit('select', shop))
markers.push(marker)
}
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-coffee-900 truncate">{{ shop.record.name }}</span>
<span
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
:class="shop.record.status === 'visited'
? 'bg-green-100 text-green-700'
: 'bg-amber-100 text-amber-700'"
>
{{ shop.record.status === 'visited' ? '✅ Visited' : '☕ Want to go' }}
</span>
</div>
<p v-if="expanded && shop.record.note" class="text-sm text-coffee-700 mt-2">
{{ shop.record.note }}
</p>
</div>
<button v-if="expanded" @click="emit('close')" class="text-coffee-400 hover:text-coffee-700 shrink-0 text-lg leading-none">
×
</button>
</div>
<!-- Actions (expanded only) -->
<div v-if="expanded" class="flex gap-2 mt-3">
<button
v-if="shop.record.status === 'want'"
@click="toggleStatus"
:disabled="busy"
class="text-xs bg-green-600 hover:bg-green-700 disabled:opacity-60 text-white px-3 py-1.5 rounded-lg transition-colors"
>
Mark visited
</button>
<button
v-else
@click="toggleStatus"
:disabled="busy"
class="text-xs bg-amber-600 hover:bg-amber-700 disabled:opacity-60 text-white px-3 py-1.5 rounded-lg transition-colors"
>
Mark want to go
</button>
<button
@click="remove"
:disabled="busy"
class="text-xs bg-red-100 hover:bg-red-200 disabled:opacity-60 text-red-700 px-3 py-1.5 rounded-lg transition-colors ml-auto"
>
Delete
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { Shop } from '@/stores/shops'
import { useShopsStore } from '@/stores/shops'
const props = defineProps<{ shop: Shop; expanded?: boolean }>()
const emit = defineEmits<{ close: [] }>()
const shopsStore = useShopsStore()
const busy = ref(false)
async function toggleStatus() {
busy.value = true
try {
await shopsStore.updateStatus(props.shop, props.shop.record.status === 'want' ? 'visited' : 'want')
emit('close')
} finally {
busy.value = false
}
}
async function remove() {
if (!confirm(`Delete "${props.shop.record.name}"?`)) return
busy.value = true
try {
await shopsStore.deleteShop(props.shop)
emit('close')
} finally {
busy.value = false
}
}
</script>

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,
}
}

11
src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

30
src/router/index.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/HomeView.vue'),
meta: { requiresAuth: true },
},
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
},
{
path: '/oauth/callback',
component: () => import('@/views/OAuthCallbackView.vue'),
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { path: '/login' }
}
})
export default router

67
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { Agent } from '@atproto/api'
import type { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { getOAuthClient } from '@/lib/atproto'
export const useAuthStore = defineStore('auth', () => {
const agent = ref<Agent | null>(null)
const did = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const isLoggedIn = computed(() => agent.value !== null)
// Called on app startup and on the OAuth callback route
async function init(): Promise<{ isCallback: boolean }> {
loading.value = true
error.value = null
try {
const client = getOAuthClient()
const result = await client.init()
if (result?.session) {
// Either a restored session or a fresh one from OAuth callback
agent.value = new Agent(result.session)
did.value = result.session.did
return { isCallback: 'state' in (result as Record<string, unknown>) }
}
return { isCallback: false }
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
return { isCallback: false }
} finally {
loading.value = false
}
}
async function signIn(handle: string): Promise<void> {
loading.value = true
error.value = null
try {
const client: BrowserOAuthClient = getOAuthClient()
// This redirects the browser — it never returns normally
await client.signIn(handle, { scope: 'atproto transition:generic' })
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
loading.value = false
}
}
async function signOut(): Promise<void> {
const client = getOAuthClient()
if (did.value) {
try {
const session = await client.restore(did.value)
await session?.signOut()
} catch {
// Ignore — clear local state anyway
}
}
agent.value = null
did.value = null
}
return { agent, did, loading, error, isLoggedIn, init, signIn, signOut }
})

122
src/stores/shops.ts Normal file
View File

@@ -0,0 +1,122 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, set, del } from 'idb-keyval'
import { useAuthStore } from './auth'
import { COLLECTION, createCoffeeSpotRecord } from '@/lib/lexicon'
import type { CoffeeSpotRecord, ShopStatus } from '@/lib/lexicon'
export interface Shop {
uri: string
cid: string
rkey: string
record: CoffeeSpotRecord
}
const IDB_KEY = 'coffeespot:cache'
export const useShopsStore = defineStore('shops', () => {
const shops = ref<Shop[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function loadFromCache(): Promise<void> {
const cached = await get<Shop[]>(IDB_KEY)
if (cached) shops.value = cached
}
async function saveToCache(): Promise<void> {
await set(IDB_KEY, shops.value)
}
async function fetchAll(): Promise<void> {
const auth = useAuthStore()
if (!auth.agent || !auth.did) return
loading.value = true
error.value = null
try {
const res = await auth.agent.com.atproto.repo.listRecords({
repo: auth.did,
collection: COLLECTION,
limit: 100,
})
shops.value = res.data.records.map((r) => ({
uri: r.uri,
cid: r.cid,
rkey: r.uri.split('/').at(-1) ?? '',
record: r.value as CoffeeSpotRecord,
}))
await saveToCache()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
// Fall back to cache
await loadFromCache()
} finally {
loading.value = false
}
}
async function addShop(
fields: Pick<CoffeeSpotRecord, 'name' | 'lat' | 'lng' | 'status' | 'note'>,
): Promise<void> {
const auth = useAuthStore()
if (!auth.agent || !auth.did) throw new Error('Not logged in')
const record = createCoffeeSpotRecord(fields)
const res = await auth.agent.com.atproto.repo.createRecord({
repo: auth.did,
collection: COLLECTION,
record,
})
const rkey = res.data.uri.split('/').at(-1) ?? ''
shops.value.unshift({ uri: res.data.uri, cid: res.data.cid, rkey, record })
await saveToCache()
}
async function updateStatus(shop: Shop, status: ShopStatus): Promise<void> {
const auth = useAuthStore()
if (!auth.agent || !auth.did) throw new Error('Not logged in')
const updated: CoffeeSpotRecord = { ...shop.record, status }
await auth.agent.com.atproto.repo.putRecord({
repo: auth.did,
collection: COLLECTION,
rkey: shop.rkey,
record: updated,
})
const idx = shops.value.findIndex((s) => s.uri === shop.uri)
if (idx !== -1) shops.value[idx].record = updated
await saveToCache()
}
async function deleteShop(shop: Shop): Promise<void> {
const auth = useAuthStore()
if (!auth.agent || !auth.did) throw new Error('Not logged in')
await auth.agent.com.atproto.repo.deleteRecord({
repo: auth.did,
collection: COLLECTION,
rkey: shop.rkey,
})
shops.value = shops.value.filter((s) => s.uri !== shop.uri)
await saveToCache()
}
async function importShops(records: Omit<CoffeeSpotRecord, '$type' | 'createdAt'>[]): Promise<void> {
for (const fields of records) {
await addShop(fields)
// Respect Nominatim rate-limit if geocoding was involved upstream
await new Promise((r) => setTimeout(r, 200))
}
}
// Clear idb cache on sign-out
async function clearCache(): Promise<void> {
shops.value = []
await del(IDB_KEY)
}
return { shops, loading, error, fetchAll, loadFromCache, addShop, updateStatus, deleteShop, importShops, clearCache }
})

137
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,137 @@
<template>
<div class="h-screen flex flex-col bg-coffee-50">
<!-- Header -->
<header class="bg-coffee-800 text-white px-4 py-3 flex items-center justify-between shadow-md shrink-0">
<div class="flex items-center gap-2">
<span class="text-xl"></span>
<h1 class="font-semibold">Coffee Map</h1>
</div>
<div class="flex items-center gap-2">
<button
@click="showAddModal = true"
class="bg-coffee-600 hover:bg-coffee-500 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
>
+ Add
</button>
<button
@click="auth.signOut()"
class="text-coffee-300 hover:text-white text-sm transition-colors px-1"
title="Sign out"
>
</button>
</div>
</header>
<!-- View toggle -->
<div class="flex border-b border-coffee-200 bg-white shrink-0">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
class="flex-1 py-2.5 text-sm font-medium transition-colors"
:class="activeTab === tab.id
? 'text-coffee-800 border-b-2 border-coffee-700'
: 'text-coffee-400 hover:text-coffee-700'"
>
{{ tab.label }}
</button>
</div>
<!-- Filter bar -->
<div class="px-4 py-2 bg-white border-b border-coffee-100 flex gap-2 shrink-0">
<input
v-model="search"
type="search"
placeholder="Search…"
class="flex-1 px-3 py-1.5 text-sm border border-coffee-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-coffee-400"
/>
<select
v-model="statusFilter"
class="px-2 py-1.5 text-sm border border-coffee-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-coffee-400 text-coffee-700"
>
<option value="">All</option>
<option value="want">Want to go</option>
<option value="visited">Visited</option>
</select>
</div>
<!-- Main content -->
<div class="flex-1 overflow-hidden">
<MapView
v-show="activeTab === 'map'"
:shops="filteredShops"
@select="selectedShop = $event"
/>
<ListView
v-show="activeTab === 'list'"
:shops="filteredShops"
@select="selectedShop = $event"
/>
</div>
<!-- Shop detail drawer -->
<Transition name="slide-up">
<div
v-if="selectedShop"
class="fixed bottom-0 left-0 right-0 bg-white rounded-t-2xl shadow-2xl p-5 z-50 max-h-60vh overflow-y-auto"
@click.self="selectedShop = null"
>
<ShopCard :shop="selectedShop" @close="selectedShop = null" expanded />
</div>
</Transition>
<!-- Add shop modal -->
<AddShopModal v-if="showAddModal" @close="showAddModal = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useShopsStore } from '@/stores/shops'
import type { Shop } from '@/stores/shops'
import MapView from '@/components/MapView.vue'
import ListView from '@/components/ListView.vue'
import ShopCard from '@/components/ShopCard.vue'
import AddShopModal from '@/components/AddShopModal.vue'
const auth = useAuthStore()
const shopsStore = useShopsStore()
const activeTab = ref<'map' | 'list'>('map')
const search = ref('')
const statusFilter = ref<'' | 'want' | 'visited'>('')
const selectedShop = ref<Shop | null>(null)
const showAddModal = ref(false)
const tabs = [
{ id: 'map' as const, label: '🗺 Map' },
{ id: 'list' as const, label: '☰ List' },
]
const filteredShops = computed(() =>
shopsStore.shops.filter((s) => {
const q = search.value.toLowerCase()
const matchesSearch =
!q ||
s.record.name.toLowerCase().includes(q) ||
s.record.note?.toLowerCase().includes(q)
const matchesStatus = !statusFilter.value || s.record.status === statusFilter.value
return matchesSearch && matchesStatus
}),
)
onMounted(() => shopsStore.fetchAll())
</script>
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.25s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>

60
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,60 @@
<template>
<div class="min-h-screen bg-coffee-50 flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<div class="text-5xl mb-3"></div>
<h1 class="text-2xl font-bold text-coffee-900">Coffee Map</h1>
<p class="text-coffee-600 mt-1 text-sm">Track your Parisian coffee spots</p>
</div>
<form @submit.prevent="handleSubmit" class="bg-white rounded-2xl shadow-sm border border-coffee-100 p-6 space-y-4">
<div>
<label for="handle" class="block text-sm font-medium text-coffee-800 mb-1">
Your ATProto handle or DID
</label>
<input
id="handle"
v-model="handle"
type="text"
placeholder="you.bsky.social or your.pds.domain"
autocomplete="username"
required
class="w-full px-3 py-2 border border-coffee-200 rounded-lg text-coffee-900 placeholder-coffee-300 focus:outline-none focus:ring-2 focus:ring-coffee-400 focus:border-transparent"
/>
</div>
<p v-if="error" class="text-red-600 text-sm">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full bg-coffee-700 hover:bg-coffee-800 disabled:opacity-60 text-white font-medium py-2.5 rounded-lg transition-colors"
>
{{ loading ? 'Redirecting…' : 'Sign in with ATProto' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const handle = ref('')
const loading = ref(false)
const error = ref('')
async function handleSubmit() {
loading.value = true
error.value = ''
try {
await auth.signIn(handle.value.trim())
} catch (e) {
error.value = e instanceof Error ? e.message : 'Sign in failed'
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="min-h-screen bg-coffee-50 flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-4"></div>
<p v-if="error" class="text-red-600">{{ error }}</p>
<p v-else class="text-coffee-600">Signing you in</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useShopsStore } from '@/stores/shops'
const router = useRouter()
const auth = useAuthStore()
const shops = useShopsStore()
const error = ref('')
onMounted(async () => {
try {
await auth.init()
if (auth.isLoggedIn) {
await shops.fetchAll()
router.replace('/')
} else {
error.value = 'Authentication failed. Please try again.'
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Authentication error'
}
})
</script>

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"exclude": ["node_modules"]
}

25
uno.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig, presetUno, presetAttributify } from 'unocss'
export default defineConfig({
presets: [
presetUno(),
presetAttributify(),
],
theme: {
colors: {
coffee: {
50: '#fdf6ec',
100: '#f9e8cc',
200: '#f3cea0',
300: '#ecac6a',
400: '#e5843c',
500: '#df661f',
600: '#c85018',
700: '#a63d17',
800: '#87321a',
900: '#6f2b19',
950: '#3d130b',
},
},
},
})

64
vite.config.ts Normal file
View File

@@ -0,0 +1,64 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import UnoCSS from 'unocss/vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
plugins: [
UnoCSS(),
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icons/*.png', 'icons/*.svg'],
manifest: {
name: 'Coffee Map',
short_name: 'Coffee',
description: 'Track coffee shops you want to visit',
theme_color: '#6f4e37',
background_color: '#fdf6ec',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{
src: 'icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
// Cache OpenFreeMap tiles
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 30 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
}),
],
})