feat: publication management with cover images, PDFs, and fruit images (story #04)

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>
This commit is contained in:
2026-06-18 10:48:08 +02:00
co-authored by Claude Sonnet 4.6
parent 2e3f0f366c
commit fce4d67cbe
18 changed files with 3116 additions and 0 deletions
+41
View File
@@ -4,6 +4,8 @@ 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()
@@ -26,9 +28,19 @@ 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) {
@@ -258,6 +270,35 @@ async function removeImage(imageId: number) {
</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>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { usePublicationStore } from '../stores/publicationStore'
const router = useRouter()
const store = usePublicationStore()
const title = ref('')
const author = ref('')
const osdbPubId = ref('')
const saving = ref(false)
const serverError = ref<string | null>(null)
async function submit() {
serverError.value = null
saving.value = true
try {
const pub = await store.create({
title: title.value,
author: author.value || null,
osdb_pub_id: osdbPubId.value,
})
router.push(`/publications/${pub.id}`)
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-lg p-6">
<h1 class="mb-4 text-2xl font-bold">Neue Publikation</h1>
<div v-if="serverError" class="mb-4 rounded bg-red-50 p-3 text-red-700">{{ serverError }}</div>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm font-medium">Titel *</label>
<input v-model="title" required class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Autor</label>
<input v-model="author" class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
<input v-model="osdbPubId" required class="w-full rounded border px-3 py-2 font-mono" />
</div>
<div class="flex gap-2">
<button
type="submit"
:disabled="saving"
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{{ saving ? 'Speichern…' : 'Anlegen' }}
</button>
<router-link to="/publications" class="rounded border px-4 py-2 hover:bg-gray-50">
Abbrechen
</router-link>
</div>
</form>
</div>
</template>
+419
View File
@@ -0,0 +1,419 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { usePublicationStore } from '../stores/publicationStore'
import {
uploadCoverImage,
deleteCoverImage,
listPubFruits,
linkFruit,
unlinkFruit,
listDescriptions,
uploadDescription,
deleteDescription,
listPubFruitImages,
uploadPubFruitImage,
deletePubFruitImage,
type PublicationFruit,
type PublicationDescription,
type PublicationFruitImage,
} from '../api/publications'
import ImageOverlayDrawer from '../components/ImageOverlayDrawer.vue'
const props = defineProps<{ id: string }>()
const router = useRouter()
const store = usePublicationStore()
// Edit state
const editing = ref(false)
const editTitle = ref('')
const editAuthor = ref('')
const editOsdbPubId = ref('')
const saving = ref(false)
const deleting = ref(false)
const serverError = ref<string | null>(null)
// Cover image
const coverFile = ref<File | null>(null)
const uploadingCover = ref(false)
const coverError = ref<string | null>(null)
const overlayOpen = ref(false)
// Linked fruits
const fruits = ref<PublicationFruit[]>([])
const linkFruitId = ref('')
const linkingFruit = ref(false)
// Descriptions
const descriptions = ref<PublicationDescription[]>([])
const descFile = ref<File | null>(null)
const descFruitId = ref('')
const uploadingDesc = ref(false)
const descError = ref<string | null>(null)
// Fruit images
const fruitImages = ref<PublicationFruitImage[]>([])
const fruitImgFile = ref<File | null>(null)
const fruitImgFruitId = ref('')
const uploadingFruitImg = ref(false)
const fruitImgError = ref<string | null>(null)
const pubId = () => Number(props.id)
onMounted(async () => {
await store.fetchPublication(pubId())
if (store.current) {
editTitle.value = store.current.title
editAuthor.value = store.current.author ?? ''
editOsdbPubId.value = store.current.osdb_pub_id
}
await loadSubResources()
})
async function loadSubResources() {
const [f, d, fi] = await Promise.all([
listPubFruits(pubId()),
listDescriptions(pubId()),
listPubFruitImages(pubId()),
])
fruits.value = f
descriptions.value = d
fruitImages.value = fi
}
async function save() {
serverError.value = null
saving.value = true
try {
const updated = await store.update(pubId(), {
title: editTitle.value,
author: editAuthor.value || null,
osdb_pub_id: editOsdbPubId.value,
})
editTitle.value = updated.title
editAuthor.value = updated.author ?? ''
editing.value = false
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
} finally {
saving.value = false
}
}
async function remove() {
if (!confirm('Publikation wirklich löschen?')) return
deleting.value = true
try {
await store.remove(pubId())
router.push('/publications')
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
deleting.value = false
}
}
async function uploadCover() {
if (!coverFile.value) return
coverError.value = null
uploadingCover.value = true
try {
await uploadCoverImage(pubId(), coverFile.value)
coverFile.value = null
await store.fetchPublication(pubId())
} catch (e) {
coverError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
} finally {
uploadingCover.value = false
}
}
async function removeCover() {
if (!confirm('Titelbild löschen?')) return
try {
await deleteCoverImage(pubId())
await store.fetchPublication(pubId())
} catch (e) {
coverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
}
}
async function doLinkFruit() {
const id = Number(linkFruitId.value)
if (!id) return
linkingFruit.value = true
try {
await linkFruit(pubId(), id)
linkFruitId.value = ''
fruits.value = await listPubFruits(pubId())
} catch {
// ignore fruit may not exist
} finally {
linkingFruit.value = false
}
}
async function doUnlinkFruit(fruitId: number) {
await unlinkFruit(pubId(), fruitId)
fruits.value = fruits.value.filter((f) => f.fruit_id !== fruitId)
}
async function uploadDesc() {
if (!descFile.value || !descFruitId.value) return
descError.value = null
uploadingDesc.value = true
try {
const d = await uploadDescription(pubId(), Number(descFruitId.value), descFile.value)
descriptions.value.push(d)
descFile.value = null
descFruitId.value = ''
} catch (e) {
descError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
} finally {
uploadingDesc.value = false
}
}
async function deleteDesc(descId: number) {
await deleteDescription(pubId(), descId)
descriptions.value = descriptions.value.filter((d) => d.id !== descId)
}
async function uploadFruitImg() {
if (!fruitImgFile.value || !fruitImgFruitId.value) return
fruitImgError.value = null
uploadingFruitImg.value = true
try {
const img = await uploadPubFruitImage(pubId(), Number(fruitImgFruitId.value), fruitImgFile.value)
fruitImages.value.push(img)
fruitImgFile.value = null
fruitImgFruitId.value = ''
} catch (e) {
fruitImgError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
} finally {
uploadingFruitImg.value = false
}
}
async function deleteFruitImg(imgId: number) {
await deletePubFruitImage(pubId(), imgId)
fruitImages.value = fruitImages.value.filter((i) => i.id !== imgId)
}
</script>
<template>
<div class="mx-auto max-w-3xl p-6">
<div v-if="store.loading" class="text-gray-500">Lade</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<template v-else-if="store.current">
<!-- Header -->
<div class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold">{{ store.current.title }}</h1>
<p class="text-sm text-gray-500 font-mono">{{ store.current.osdb_pub_id }}</p>
<p v-if="store.current.author" class="text-gray-700">{{ store.current.author }}</p>
</div>
<div class="flex gap-2">
<button
class="rounded border px-3 py-1 text-sm hover:bg-gray-50"
@click="editing = !editing"
>
{{ editing ? 'Abbrechen' : 'Bearbeiten' }}
</button>
<button
:disabled="deleting"
class="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:opacity-50"
@click="remove"
>
Löschen
</button>
</div>
</div>
<!-- Edit form -->
<div v-if="editing" class="mb-6 rounded border p-4">
<div v-if="serverError" class="mb-3 text-red-600">{{ serverError }}</div>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">Titel *</label>
<input v-model="editTitle" class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Autor</label>
<input v-model="editAuthor" class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
<input v-model="editOsdbPubId" class="w-full rounded border px-3 py-2 font-mono" />
</div>
<button
:disabled="saving"
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
@click="save"
>
{{ saving ? 'Speichern…' : 'Speichern' }}
</button>
</div>
</div>
<!-- Cover image -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Titelbild</h2>
<div v-if="store.current.image_url" class="mb-3 flex items-start gap-4">
<img
:src="store.current.image_url"
alt="Titelbild"
class="h-32 w-24 cursor-pointer rounded border object-cover shadow hover:opacity-90"
@click="overlayOpen = true"
/>
<button
class="rounded border px-2 py-1 text-sm text-red-600 hover:bg-red-50"
@click="removeCover"
>
Löschen
</button>
</div>
<div v-else class="mb-3 text-sm text-gray-400">Kein Titelbild vorhanden.</div>
<div v-if="coverError" class="mb-2 text-red-600 text-sm">{{ coverError }}</div>
<div class="flex items-center gap-2">
<input
type="file"
accept="image/*"
class="text-sm"
@change="coverFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
/>
<button
:disabled="!coverFile || uploadingCover"
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="uploadCover"
>
{{ uploadingCover ? 'Hochladen…' : 'Hochladen' }}
</button>
</div>
</section>
<!-- Linked fruits -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Verknüpfte Früchte</h2>
<ul v-if="fruits.length" class="mb-3 space-y-1">
<li v-for="f in fruits" :key="f.fruit_id" class="flex items-center justify-between rounded border px-3 py-1 text-sm">
<span>{{ f.name }} <span class="text-gray-400 font-mono text-xs">({{ f.osdb_number }})</span></span>
<button class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
Entfernen
</button>
</li>
</ul>
<div v-else class="mb-3 text-sm text-gray-400">Keine Früchte verknüpft.</div>
<div class="flex gap-2">
<input
v-model="linkFruitId"
type="number"
placeholder="Frucht-ID"
class="w-32 rounded border px-3 py-1 text-sm"
/>
<button
:disabled="!linkFruitId || linkingFruit"
class="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50"
@click="doLinkFruit"
>
Verknüpfen
</button>
</div>
</section>
<!-- Descriptions (PDFs) -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Beschreibungen (PDF)</h2>
<ul v-if="descriptions.length" class="mb-3 space-y-1">
<li
v-for="d in descriptions"
:key="d.id"
class="flex items-center justify-between rounded border px-3 py-1 text-sm"
>
<a :href="d.url" target="_blank" class="text-blue-600 hover:underline">
{{ d.fruit_name || `Frucht #${d.fruit_id}` }}
</a>
<button class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
Löschen
</button>
</li>
</ul>
<div v-else class="mb-3 text-sm text-gray-400">Keine Beschreibungen vorhanden.</div>
<div v-if="descError" class="mb-2 text-red-600 text-sm">{{ descError }}</div>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="descFruitId"
type="number"
placeholder="Frucht-ID"
class="w-28 rounded border px-3 py-1 text-sm"
/>
<input
type="file"
accept="application/pdf"
class="text-sm"
@change="descFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
/>
<button
:disabled="!descFile || !descFruitId || uploadingDesc"
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="uploadDesc"
>
{{ uploadingDesc ? 'Hochladen…' : 'PDF hochladen' }}
</button>
</div>
</section>
<!-- Fruit images -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Fruchtbilder</h2>
<div v-if="fruitImages.length" class="mb-3 flex flex-wrap gap-3">
<div
v-for="img in fruitImages"
:key="img.id"
class="flex flex-col items-center gap-1"
>
<img
:src="img.url"
:alt="img.filename ?? 'Fruchtbild'"
class="h-24 w-24 rounded border object-cover"
/>
<span class="text-xs text-gray-500">Frucht #{{ img.fruit_id }}</span>
<button class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
Löschen
</button>
</div>
</div>
<div v-else class="mb-3 text-sm text-gray-400">Keine Fruchtbilder vorhanden.</div>
<div v-if="fruitImgError" class="mb-2 text-red-600 text-sm">{{ fruitImgError }}</div>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="fruitImgFruitId"
type="number"
placeholder="Frucht-ID"
class="w-28 rounded border px-3 py-1 text-sm"
/>
<input
type="file"
accept="image/*"
class="text-sm"
@change="fruitImgFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
/>
<button
:disabled="!fruitImgFile || !fruitImgFruitId || uploadingFruitImg"
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="uploadFruitImg"
>
{{ uploadingFruitImg ? 'Hochladen…' : 'Bild hochladen' }}
</button>
</div>
</section>
</template>
<!-- Full-size cover image overlay -->
<ImageOverlayDrawer
v-if="overlayOpen && store.current?.image_url"
:src="store.current.image_url"
alt="Titelbild (Vollansicht)"
@close="overlayOpen = false"
/>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { usePublicationStore } from '../stores/publicationStore'
const store = usePublicationStore()
onMounted(async () => {
try {
await store.fetchPublications()
} catch {
// error already set in store
}
})
</script>
<template>
<div class="mx-auto max-w-4xl p-6">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Publikationen</h1>
<router-link
to="/publications/new"
class="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
>
Neue Publikation
</router-link>
</div>
<div v-if="store.loading" class="text-gray-500">Lade</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else-if="store.publications.length === 0" class="text-gray-500">
Keine Publikationen vorhanden.
</div>
<table v-else class="w-full border-collapse text-sm">
<thead>
<tr class="bg-gray-100 text-left">
<th class="border px-3 py-2">Titel</th>
<th class="border px-3 py-2">Autor</th>
<th class="border px-3 py-2">OSDB Kürzel</th>
<th class="border px-3 py-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="pub in store.publications" :key="pub.id" class="hover:bg-gray-50">
<td class="border px-3 py-2">{{ pub.title }}</td>
<td class="border px-3 py-2">{{ pub.author ?? '—' }}</td>
<td class="border px-3 py-2 font-mono text-xs">{{ pub.osdb_pub_id }}</td>
<td class="border px-3 py-2">
<router-link :to="`/publications/${pub.id}`" class="text-blue-600 hover:underline">
Details
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</template>