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:
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
listPublications,
|
||||
getPublication,
|
||||
createPublication,
|
||||
updatePublication,
|
||||
deletePublication,
|
||||
pubImageUrl,
|
||||
pubDescriptionUrl,
|
||||
pubFruitImageUrl,
|
||||
} from './publications'
|
||||
|
||||
function mockFetch(body: unknown, status = 200) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(String(body)),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function stubFetch(body: unknown, status = 200) {
|
||||
vi.stubGlobal('fetch', mockFetch(body, status))
|
||||
}
|
||||
|
||||
describe('listPublications', () => {
|
||||
it('returns array of publications', async () => {
|
||||
stubFetch([{ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' }])
|
||||
const pubs = await listPublications()
|
||||
expect(pubs).toHaveLength(1)
|
||||
expect(pubs[0].title).toBe('Pomologie')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPublication', () => {
|
||||
it('returns publication by id', async () => {
|
||||
stubFetch({ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' })
|
||||
const pub = await getPublication(1)
|
||||
expect(pub.id).toBe(1)
|
||||
})
|
||||
|
||||
it('throws on 404', async () => {
|
||||
stubFetch({ error: 'not found' }, 404)
|
||||
await expect(getPublication(99)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPublication', () => {
|
||||
it('returns created publication', async () => {
|
||||
stubFetch({ id: 2, title: 'New', osdb_pub_id: 'P002' }, 201)
|
||||
const pub = await createPublication({ title: 'New', osdb_pub_id: 'P002' })
|
||||
expect(pub.id).toBe(2)
|
||||
})
|
||||
|
||||
it('throws 409 on duplicate osdb_pub_id', async () => {
|
||||
stubFetch({ error: 'osdb_pub_id already exists' }, 409)
|
||||
await expect(createPublication({ title: 'New', osdb_pub_id: 'P001' })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePublication', () => {
|
||||
it('returns updated publication', async () => {
|
||||
stubFetch({ id: 1, title: 'Updated', osdb_pub_id: 'P001' })
|
||||
const pub = await updatePublication(1, { title: 'Updated', osdb_pub_id: 'P001' })
|
||||
expect(pub.title).toBe('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePublication', () => {
|
||||
it('resolves on 204', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }))
|
||||
await expect(deletePublication(1)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL helpers', () => {
|
||||
it('pubImageUrl returns correct path', () => {
|
||||
expect(pubImageUrl(3)).toBe('/api/v1/publications/3/image')
|
||||
})
|
||||
|
||||
it('pubDescriptionUrl returns correct path', () => {
|
||||
expect(pubDescriptionUrl(3, 7)).toBe('/api/v1/publications/3/descriptions/7')
|
||||
})
|
||||
|
||||
it('pubFruitImageUrl returns correct path', () => {
|
||||
expect(pubFruitImageUrl(3, 9)).toBe('/api/v1/publications/3/fruit-images/9')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
export interface Publication {
|
||||
id: number
|
||||
title: string
|
||||
author: string | null
|
||||
osdb_pub_id: string
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PublicationWriteDTO {
|
||||
title: string
|
||||
author?: string | null
|
||||
osdb_pub_id: string
|
||||
}
|
||||
|
||||
export interface PublicationFruit {
|
||||
fruit_id: number
|
||||
name: string
|
||||
osdb_number: string
|
||||
}
|
||||
|
||||
export interface PublicationDescription {
|
||||
id: number
|
||||
publication_id: number
|
||||
fruit_id: number
|
||||
fruit_name: string
|
||||
publication_title: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PublicationFruitImage {
|
||||
id: number
|
||||
publication_id: number
|
||||
publication_title: string
|
||||
publication_author: string | null
|
||||
fruit_id: number
|
||||
filename: string | null
|
||||
image_type: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function pubImageUrl(pubId: number): string {
|
||||
return `/api/v1/publications/${pubId}/image`
|
||||
}
|
||||
|
||||
export function pubDescriptionUrl(pubId: number, descId: number): string {
|
||||
return `/api/v1/publications/${pubId}/descriptions/${descId}`
|
||||
}
|
||||
|
||||
export function pubFruitImageUrl(pubId: number, imgId: number): string {
|
||||
return `/api/v1/publications/${pubId}/fruit-images/${imgId}`
|
||||
}
|
||||
|
||||
async function checkOk(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
msg = body.error ?? body.errors?.join(', ') ?? msg
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const err = new Error(msg) as Error & { status: number }
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export async function listPublications(): Promise<Publication[]> {
|
||||
const res = await fetch('/api/v1/publications')
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getPublication(id: number): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function createPublication(dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch('/api/v1/publications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function updatePublication(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePublication(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function uploadCoverImage(pubId: number, file: File): Promise<void> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function deleteCoverImage(pubId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listPubFruits(pubId: number): Promise<PublicationFruit[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function linkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fruit_id: fruitId }),
|
||||
})
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function unlinkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listDescriptions(pubId: number): Promise<PublicationDescription[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function uploadDescription(pubId: number, fruitId: number, file: File): Promise<PublicationDescription> {
|
||||
const form = new FormData()
|
||||
form.append('pdf', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteDescription(pubId: number, descId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listPubFruitImages(pubId: number): Promise<PublicationFruitImage[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function uploadPubFruitImage(pubId: number, fruitId: number, file: File): Promise<PublicationFruitImage> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePubFruitImage(pubId: number, imgId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function getFruitDescriptions(fruitId: number): Promise<PublicationDescription[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/descriptions`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getFruitPublicationImages(fruitId: number): Promise<PublicationFruitImage[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/publication-images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ src: string; alt?: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="relative max-h-screen max-w-screen-lg p-4">
|
||||
<button
|
||||
class="absolute right-2 top-2 rounded bg-white/90 px-2 py-1 text-sm font-bold shadow hover:bg-white"
|
||||
@click="emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<img :src="src" :alt="alt ?? 'Bild'" class="max-h-[85vh] max-w-full rounded shadow-lg object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,9 @@ import HelloWorld from '../views/HelloWorld.vue'
|
||||
import FruitList from '../views/FruitList.vue'
|
||||
import FruitCreate from '../views/FruitCreate.vue'
|
||||
import FruitDetail from '../views/FruitDetail.vue'
|
||||
import PublicationList from '../views/PublicationList.vue'
|
||||
import PublicationCreate from '../views/PublicationCreate.vue'
|
||||
import PublicationDetail from '../views/PublicationDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -25,6 +28,20 @@ const router = createRouter({
|
||||
component: FruitDetail,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/publications',
|
||||
component: PublicationList,
|
||||
},
|
||||
{
|
||||
// /publications/new must come before /:id
|
||||
path: '/publications/new',
|
||||
component: PublicationCreate,
|
||||
},
|
||||
{
|
||||
path: '/publications/:id',
|
||||
component: PublicationDetail,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
listPublications,
|
||||
getPublication,
|
||||
createPublication,
|
||||
updatePublication,
|
||||
deletePublication,
|
||||
type Publication,
|
||||
type PublicationWriteDTO,
|
||||
} from '../api/publications'
|
||||
|
||||
export const usePublicationStore = defineStore('publication', () => {
|
||||
const publications = ref<Publication[]>([])
|
||||
const current = ref<Publication | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchPublications() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
publications.value = await listPublications()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublication(id: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
current.value = await getPublication(id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||
current.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const pub = await createPublication(dto)
|
||||
publications.value = [pub, ...publications.value]
|
||||
return pub
|
||||
}
|
||||
|
||||
async function update(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const pub = await updatePublication(id, dto)
|
||||
const idx = publications.value.findIndex((p) => p.id === id)
|
||||
if (idx !== -1) publications.value[idx] = pub
|
||||
if (current.value?.id === id) current.value = pub
|
||||
return pub
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await deletePublication(id)
|
||||
publications.value = publications.value.filter((p) => p.id !== id)
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
return { publications, current, loading, error, fetchPublications, fetchPublication, create, update, remove }
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user