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:
@@ -38,6 +38,7 @@ export interface Fruit {
|
||||
fruit_type: string
|
||||
synonyms: string[]
|
||||
images: FruitImage[]
|
||||
thumbnail_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user