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>
138 lines
4.2 KiB
Vue
138 lines
4.2 KiB
Vue
<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>
|