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:
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>
|
||||
Reference in New Issue
Block a user