Backend: domain structs, FruitRepository interface + pg implementation,
9 Echo v4 handlers (list/get/create/update/delete, image sub-resources),
migration 000002 (fruit_type ENUM, fruits, fruit_synonyms, fruit_images),
route-scoped BodyLimit("5M") for uploads, http.DetectContentType for serving.
Frontend: typed fetch API layer, Pinia setup-style fruitStore, FruitList
(paginated), FruitCreate, and FruitDetail (edit + synonyms editor + image
gallery). 25 backend unit tests + integration test; 18 frontend tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
101 lines
3.4 KiB
Vue
101 lines
3.4 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, computed } from 'vue'
|
|
import { RouterLink } from 'vue-router'
|
|
import { useFruitStore } from '../stores/fruitStore'
|
|
|
|
const store = useFruitStore()
|
|
|
|
onMounted(async () => {
|
|
await store.fetchFruits()
|
|
})
|
|
|
|
const hasPrev = computed(() => store.offset > 0)
|
|
const hasNext = computed(() => store.offset + store.limit < store.total)
|
|
|
|
async function prev() {
|
|
await store.fetchFruits(store.limit, Math.max(0, store.offset - store.limit))
|
|
}
|
|
|
|
async function next() {
|
|
await store.fetchFruits(store.limit, store.offset + store.limit)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="p-6 max-w-4xl mx-auto">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold text-gray-900">Früchte</h1>
|
|
<RouterLink
|
|
to="/fruits/new"
|
|
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition-colors"
|
|
>
|
|
Neue Frucht
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<!-- Search placeholder (story #06) -->
|
|
<div class="mb-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Suche... (kommt in Story #06)"
|
|
disabled
|
|
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
|
|
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
|
<div v-else>
|
|
<table class="w-full border-collapse border border-gray-200 rounded">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Name</th>
|
|
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">OSDB-Kürzel</th>
|
|
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Typ</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="store.fruits.length === 0">
|
|
<td colspan="3" class="border border-gray-200 px-4 py-8 text-center text-gray-400">
|
|
Keine Früchte vorhanden.
|
|
</td>
|
|
</tr>
|
|
<tr
|
|
v-for="fruit in store.fruits"
|
|
:key="fruit.id"
|
|
class="hover:bg-gray-50 cursor-pointer"
|
|
>
|
|
<td class="border border-gray-200 px-4 py-2">
|
|
<RouterLink :to="`/fruits/${fruit.id}`" class="text-green-700 hover:underline">
|
|
{{ fruit.name }}
|
|
</RouterLink>
|
|
</td>
|
|
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.osdb_number }}</td>
|
|
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.fruit_type }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
|
<span>{{ store.total }} Früchte gesamt</span>
|
|
<div class="flex gap-2">
|
|
<button
|
|
@click="prev"
|
|
:disabled="!hasPrev"
|
|
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100 transition-colors"
|
|
>
|
|
← Zurück
|
|
</button>
|
|
<button
|
|
@click="next"
|
|
:disabled="!hasNext"
|
|
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100 transition-colors"
|
|
>
|
|
Weiter →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|