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

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>