feat: fruit overview with thumbnails and card layout (story #07)

- Auto-crop thumbnails on image upload (Go stdlib, no external deps):
  find non-background bounding box, pad 5px, scale to ≤300px, encode JPEG
- Add thumbnail_data BYTEA column to fruit_images and publication_fruit_images
- New GET /api/v1/fruits/:id/images/:imageId/thumbnail endpoint (fallback to full data)
- New GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail endpoint
- New POST /api/v1/admin/backfill-thumbnails to retroactively generate thumbnails
- ListFruits response now includes synonyms and thumbnail_url per fruit
- Replace FruitList table with responsive FruitCard grid (thumbnail + name + OSDB ID + type + synonyms)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 09:02:14 +02:00
co-authored by Claude Sonnet 4.6
parent 2525e8b68d
commit 649c9687ac
17 changed files with 591 additions and 48 deletions
+1
View File
@@ -38,6 +38,7 @@ export interface Fruit {
fruit_type: string
synonyms: string[]
images: FruitImage[]
thumbnail_url: string | null
created_at: string
updated_at: string
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import type { Fruit } from '../api/fruits'
defineProps<{ fruit: Fruit }>()
</script>
<template>
<RouterLink
:to="`/fruits/${fruit.id}`"
class="block bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow"
>
<div class="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
<img
v-if="fruit.thumbnail_url"
:src="fruit.thumbnail_url"
:alt="fruit.name"
class="max-w-full max-h-full object-contain"
/>
<span v-else class="text-gray-300 text-4xl select-none">🌿</span>
</div>
<div class="p-3">
<p class="font-semibold text-gray-900 truncate">{{ fruit.name }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ fruit.osdb_number }}</p>
<p class="text-xs text-green-700 mt-0.5">{{ fruit.fruit_type }}</p>
<p v-if="fruit.synonyms.length" class="text-xs text-gray-400 mt-1 truncate">
{{ fruit.synonyms.join(', ') }}
</p>
</div>
</RouterLink>
</template>
+1
View File
@@ -11,6 +11,7 @@ const makeFruit = (id: number, name = 'Boskop'): Fruit => ({
fruit_type: 'Apfelsorten',
synonyms: [],
images: [],
thumbnail_url: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
+10 -29
View File
@@ -2,6 +2,7 @@
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore'
import FruitCard from '../components/FruitCard.vue'
const SEARCH_TYPES = [
{ label: '(Alle)', value: '' },
@@ -105,35 +106,15 @@ function onTypeChange() {
<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 v-if="store.fruits.length === 0" class="text-center text-gray-400 py-16">
Keine Früchte vorhanden.
</div>
<div
v-else
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
<FruitCard v-for="fruit in store.fruits" :key="fruit.id" :fruit="fruit" />
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>{{ store.total }} Früchte gesamt</span>