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:
3
.env.example
Normal file
3
.env.example
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
14
index.html
Normal 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
31
nginx.conf
Normal 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
30
package.json
Normal 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
6002
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
public/client-metadata.json
Normal file
12
public/client-metadata.json
Normal 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
26
src/App.vue
Normal 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>
|
||||
246
src/components/AddShopModal.vue
Normal file
246
src/components/AddShopModal.vue
Normal 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>
|
||||
26
src/components/ListView.vue
Normal file
26
src/components/ListView.vue
Normal 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>
|
||||
64
src/components/MapView.vue
Normal file
64
src/components/MapView.vue
Normal 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>
|
||||
84
src/components/ShopCard.vue
Normal file
84
src/components/ShopCard.vue
Normal 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
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,
|
||||
}
|
||||
}
|
||||
11
src/main.ts
Normal file
11
src/main.ts
Normal 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
30
src/router/index.ts
Normal 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
67
src/stores/auth.ts
Normal 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
122
src/stores/shops.ts
Normal 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
137
src/views/HomeView.vue
Normal 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
60
src/views/LoginView.vue
Normal 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>
|
||||
35
src/views/OAuthCallbackView.vue
Normal file
35
src/views/OAuthCallbackView.vue
Normal 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
24
tsconfig.json
Normal 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
25
uno.config.ts
Normal 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
64
vite.config.ts
Normal 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] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user