Files
osdb-1-claude-opusplan/frontend/src/views/PublicationDetail.vue
T
juliaandClaude Sonnet 4.6 1b381d9385 feat: protect write endpoints with JWT auth (story #08)
Add bcrypt user file auth, JWT middleware on all write routes, Pinia
authStore with localStorage persistence, login view with redirect
support, and v-if guards on all CRUD controls and admin nav link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:18:42 +02:00

423 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { usePublicationStore } from '../stores/publicationStore'
import { useAuthStore } from '../stores/authStore'
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()
const auth = useAuthStore()
// 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 v-if="auth.isLoggedIn" 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="auth.isLoggedIn && 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
v-if="auth.isLoggedIn"
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 v-if="auth.isLoggedIn" 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 v-if="auth.isLoggedIn" 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 v-if="auth.isLoggedIn" 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 v-if="auth.isLoggedIn" 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 v-if="auth.isLoggedIn" 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 v-if="auth.isLoggedIn" 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 v-if="auth.isLoggedIn" 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>