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>
423 lines
14 KiB
Vue
423 lines
14 KiB
Vue
<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>
|