Full CRUD for publications; link fruits to publications; upload per-fruit PDF descriptions and fruit images stored as BYTEA; fruit detail page shows publication descriptions and images. ImageOverlayDrawer for cover thumbnail full-size view. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
308 lines
12 KiB
Vue
308 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useFruitStore } from '../stores/fruitStore'
|
|
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
|
|
import type { Fruit } from '../api/fruits'
|
|
import { getFruitDescriptions, getFruitPublicationImages } from '../api/publications'
|
|
import type { PublicationDescription, PublicationFruitImage } from '../api/publications'
|
|
|
|
const props = defineProps<{ id: string }>()
|
|
const router = useRouter()
|
|
const store = useFruitStore()
|
|
|
|
const editing = ref(false)
|
|
const editName = ref('')
|
|
const editOsdbNumber = ref('')
|
|
const editComment = ref('')
|
|
const editFruitType = ref('')
|
|
const editSynonyms = ref<string[]>([])
|
|
const synonymInput = ref('')
|
|
const saving = ref(false)
|
|
const deleting = ref(false)
|
|
const serverError = ref<string | null>(null)
|
|
|
|
const imageFile = ref<File | null>(null)
|
|
const imageType = ref('fruit')
|
|
const imageTitle = ref('')
|
|
const uploading = ref(false)
|
|
const uploadError = ref<string | null>(null)
|
|
|
|
const pubDescriptions = ref<PublicationDescription[]>([])
|
|
const pubFruitImages = ref<PublicationFruitImage[]>([])
|
|
|
|
onMounted(async () => {
|
|
await store.fetchFruit(Number(props.id))
|
|
if (store.current) startEdit(store.current)
|
|
const id = Number(props.id)
|
|
const [descs, imgs] = await Promise.all([
|
|
getFruitDescriptions(id),
|
|
getFruitPublicationImages(id),
|
|
])
|
|
pubDescriptions.value = descs
|
|
pubFruitImages.value = imgs
|
|
})
|
|
|
|
function startEdit(f: Fruit) {
|
|
editName.value = f.name
|
|
editOsdbNumber.value = f.osdb_number
|
|
editComment.value = f.comment ?? ''
|
|
editFruitType.value = f.fruit_type
|
|
editSynonyms.value = [...f.synonyms]
|
|
}
|
|
|
|
function addSynonym() {
|
|
const s = synonymInput.value.trim()
|
|
if (s && !editSynonyms.value.includes(s)) editSynonyms.value.push(s)
|
|
synonymInput.value = ''
|
|
}
|
|
|
|
function removeSynonym(idx: number) {
|
|
editSynonyms.value.splice(idx, 1)
|
|
}
|
|
|
|
async function save() {
|
|
if (!store.current) return
|
|
serverError.value = null
|
|
saving.value = true
|
|
try {
|
|
const updated = await store.update(Number(props.id), {
|
|
name: editName.value,
|
|
osdb_number: editOsdbNumber.value,
|
|
comment: editComment.value || null,
|
|
fruit_type: editFruitType.value,
|
|
synonyms: editSynonyms.value,
|
|
})
|
|
startEdit(updated)
|
|
editing.value = false
|
|
} catch (e) {
|
|
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
if (!confirm('Frucht wirklich löschen?')) return
|
|
deleting.value = true
|
|
try {
|
|
await store.remove(Number(props.id))
|
|
router.push('/fruits')
|
|
} catch (e) {
|
|
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
|
deleting.value = false
|
|
}
|
|
}
|
|
|
|
function onFileChange(e: Event) {
|
|
const input = e.target as HTMLInputElement
|
|
imageFile.value = input.files?.[0] ?? null
|
|
}
|
|
|
|
async function uploadImage() {
|
|
if (!imageFile.value) return
|
|
uploadError.value = null
|
|
uploading.value = true
|
|
try {
|
|
await addImage(Number(props.id), imageFile.value, imageType.value, imageTitle.value || undefined)
|
|
imageFile.value = null
|
|
imageTitle.value = ''
|
|
await store.fetchFruit(Number(props.id))
|
|
} catch (e) {
|
|
uploadError.value = e instanceof Error ? e.message : 'Upload fehlgeschlagen'
|
|
} finally {
|
|
uploading.value = false
|
|
}
|
|
}
|
|
|
|
async function removeImage(imageId: number) {
|
|
if (!confirm('Bild wirklich löschen?')) return
|
|
try {
|
|
await deleteImage(Number(props.id), imageId)
|
|
await store.fetchFruit(Number(props.id))
|
|
} catch (e) {
|
|
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen des Bildes'
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="p-6 max-w-3xl mx-auto">
|
|
<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-if="!store.current" class="text-gray-400">Frucht nicht gefunden.</div>
|
|
<div v-else>
|
|
<div class="flex items-start justify-between mb-6">
|
|
<h1 class="text-2xl font-bold text-gray-900">{{ store.current.name }}</h1>
|
|
<div class="flex gap-2">
|
|
<button
|
|
v-if="!editing"
|
|
@click="editing = true"
|
|
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors text-sm"
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
@click="remove"
|
|
:disabled="deleting"
|
|
class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50 transition-colors text-sm"
|
|
>
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="serverError" class="mb-4 p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
|
|
{{ serverError }}
|
|
</div>
|
|
|
|
<!-- View / Edit form -->
|
|
<div class="space-y-4">
|
|
<div v-if="!editing" class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span class="font-medium text-gray-700">OSDB-Kürzel:</span>
|
|
<span class="ml-2 text-gray-900">{{ store.current.osdb_number }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-gray-700">Typ:</span>
|
|
<span class="ml-2 text-gray-900">{{ store.current.fruit_type }}</span>
|
|
</div>
|
|
<div v-if="store.current.comment" class="col-span-2">
|
|
<span class="font-medium text-gray-700">Kommentar:</span>
|
|
<span class="ml-2 text-gray-900">{{ store.current.comment }}</span>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<span class="font-medium text-gray-700">Synonyme:</span>
|
|
<span v-if="store.current.synonyms.length === 0" class="ml-2 text-gray-400">keine</span>
|
|
<span v-else class="ml-2 text-gray-900">{{ store.current.synonyms.join(', ') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<form v-else @submit.prevent="save" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
|
<input v-model="editName" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">OSDB-Kürzel *</label>
|
|
<input v-model="editOsdbNumber" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
|
|
<select v-model="editFruitType" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500">
|
|
<option v-for="t in FRUIT_TYPES" :key="t" :value="t">{{ t }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
|
|
<textarea v-model="editComment" rows="3" class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Synonyme</label>
|
|
<div class="flex gap-2 mb-2">
|
|
<input v-model="synonymInput" type="text" @keydown.enter.prevent="addSynonym" placeholder="Synonym hinzufügen" class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
|
<button type="button" @click="addSynonym" class="bg-gray-200 px-3 py-2 rounded hover:bg-gray-300">+</button>
|
|
</div>
|
|
<ul class="space-y-1">
|
|
<li v-for="(s, i) in editSynonyms" :key="i" class="flex items-center gap-2 text-sm">
|
|
<span class="flex-1 bg-gray-100 rounded px-2 py-1">{{ s }}</span>
|
|
<button type="button" @click="removeSynonym(i)" class="text-red-500 hover:text-red-700 text-xs">✕</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button type="submit" :disabled="saving" class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors">
|
|
{{ saving ? 'Speichern...' : 'Speichern' }}
|
|
</button>
|
|
<button type="button" @click="editing = false" class="px-6 py-2 border rounded hover:bg-gray-50 transition-colors">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Image gallery -->
|
|
<div class="mt-8">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Bilder</h2>
|
|
|
|
<div v-if="store.current.images.length === 0" class="text-gray-400 text-sm mb-4">Keine Bilder vorhanden.</div>
|
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
|
<div v-for="img in store.current.images" :key="img.id" class="relative group border rounded overflow-hidden">
|
|
<img :src="img.url" :alt="img.title ?? img.image_type" class="w-full h-32 object-cover" />
|
|
<div class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
@click="removeImage(img.id)"
|
|
class="bg-red-600 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-700"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div class="p-1 text-xs text-gray-500">{{ img.image_type }}{{ img.title ? ' · ' + img.title : '' }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload form -->
|
|
<div class="border rounded p-4 bg-gray-50">
|
|
<h3 class="text-sm font-medium text-gray-700 mb-3">Bild hochladen</h3>
|
|
<div v-if="uploadError" class="mb-3 text-red-600 text-sm">{{ uploadError }}</div>
|
|
<div class="space-y-3">
|
|
<input type="file" accept="image/*" @change="onFileChange" class="block w-full text-sm text-gray-600" />
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Bildtyp *</label>
|
|
<select v-model="imageType" class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
|
|
<option value="fruit">Frucht</option>
|
|
<option value="flower">Blüte</option>
|
|
<option value="tree">Baum</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Titel (optional)</label>
|
|
<input v-model="imageTitle" type="text" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full focus:outline-none focus:ring-2 focus:ring-green-500" />
|
|
</div>
|
|
<button
|
|
@click="uploadImage"
|
|
:disabled="!imageFile || uploading"
|
|
class="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{{ uploading ? 'Hochladen...' : 'Hochladen' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Publication descriptions -->
|
|
<div v-if="pubDescriptions.length > 0" class="mt-8">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-3">Beschreibungen in Publikationen</h2>
|
|
<ul class="space-y-1">
|
|
<li v-for="d in pubDescriptions" :key="d.id">
|
|
<a
|
|
:href="d.url"
|
|
target="_blank"
|
|
class="text-blue-600 hover:underline text-sm"
|
|
>
|
|
{{ d.publication_title }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Publication fruit images -->
|
|
<div v-if="pubFruitImages.length > 0" class="mt-8">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-3">Bilder aus Publikationen</h2>
|
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
<div v-for="img in pubFruitImages" :key="img.id" class="border rounded overflow-hidden">
|
|
<img :src="img.url" :alt="img.filename ?? 'Fruchtbild'" class="w-full h-32 object-cover" />
|
|
<div class="p-1 text-xs text-gray-500">
|
|
{{ img.publication_title }}{{ img.publication_author ? ' · ' + img.publication_author : '' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6">
|
|
<RouterLink to="/fruits" class="text-green-700 hover:underline text-sm">← Zurück zur Liste</RouterLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|