Add sharp image processing library and update dependencies in package.json and package-lock.json; enhance Navigation component with new 'Galerie' link for improved user navigation.
This commit is contained in:
430
pages/verein/galerie.vue
Normal file
430
pages/verein/galerie.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div class="min-h-screen py-16 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-8">
|
||||
Bildergalerie
|
||||
</h1>
|
||||
|
||||
<!-- Galerie-Grid -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<p class="text-gray-500">Bilder werden geladen...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="images.length === 0" class="text-center py-12">
|
||||
<p class="text-gray-500">Noch keine Bilder in der Galerie.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="group relative bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden bg-gray-100 cursor-pointer flex items-center justify-center w-full"
|
||||
style="min-height: 150px;"
|
||||
@click="openModal(image)"
|
||||
>
|
||||
<img
|
||||
:src="`/api/galerie/${image.id}?preview=true`"
|
||||
:alt="image.title"
|
||||
class="object-cover group-hover:scale-105 transition-transform duration-300 pointer-events-none"
|
||||
style="max-width: 150px; max-height: 150px; width: auto; height: auto;"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-1 cursor-pointer" @click="openModal(image)">{{ image.title }}</h3>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<span
|
||||
v-if="!image.isPublic"
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
Nur für Mitglieder
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatDate(image.uploadedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Lösch-Button für Admins -->
|
||||
<button
|
||||
v-if="isAdmin || isVorstand"
|
||||
@click.stop="deleteImage(image.id)"
|
||||
:disabled="deleting === image.id"
|
||||
class="mt-2 w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ deleting === image.id ? 'Wird gelöscht...' : 'Löschen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagination.totalPages > 1" class="flex justify-center items-center space-x-2 mb-8">
|
||||
<button
|
||||
@click="changePage(pagination.page - 1)"
|
||||
:disabled="pagination.page === 1"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span class="px-4 py-2 text-gray-700">
|
||||
Seite {{ pagination.page }} von {{ pagination.totalPages }} ({{ pagination.total }} Bilder)
|
||||
</span>
|
||||
<button
|
||||
@click="changePage(pagination.page + 1)"
|
||||
:disabled="pagination.page >= pagination.totalPages"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload-Bereich für Admins (Collapsible) -->
|
||||
<div v-if="isAdmin || isVorstand" class="mt-12">
|
||||
<button
|
||||
@click="showUploadForm = !showUploadForm"
|
||||
class="w-full flex items-center justify-between p-4 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-900">Bild hochladen</h2>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-600 transition-transform"
|
||||
:class="{ 'rotate-180': showUploadForm }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showUploadForm" class="mt-4 p-6 bg-gray-50 rounded-lg border border-gray-300">
|
||||
<form @submit.prevent="uploadImage" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bilddatei
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
@change="handleFileSelect"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-600 file:text-white hover:file:bg-primary-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
v-model="uploadForm.title"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="Titel des Bildes"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="uploadForm.description"
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="Beschreibung des Bildes"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="uploadForm.isPublic"
|
||||
type="checkbox"
|
||||
id="isPublic"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="isPublic" class="ml-2 block text-sm text-gray-700">
|
||||
Öffentlich sichtbar (für alle Besucher)
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="uploading"
|
||||
class="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ uploading ? 'Wird hochgeladen...' : 'Bild hochladen' }}
|
||||
</button>
|
||||
<p v-if="uploadError" class="text-red-600 text-sm">{{ uploadError }}</p>
|
||||
<p v-if="uploadSuccess" class="text-green-600 text-sm">{{ uploadSuccess }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox Modal -->
|
||||
<div
|
||||
v-if="selectedImage"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
|
||||
@click="closeModal"
|
||||
>
|
||||
<div class="relative max-w-5xl max-h-full w-full" @click.stop>
|
||||
<!-- Schließen-Button -->
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-2"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Vorheriges Bild Button -->
|
||||
<button
|
||||
v-if="hasPreviousImage"
|
||||
@click.stop="showPreviousImage"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
|
||||
aria-label="Vorheriges Bild"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Nächstes Bild Button -->
|
||||
<button
|
||||
v-if="hasNextImage"
|
||||
@click.stop="showNextImage"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
|
||||
aria-label="Nächstes Bild"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
:src="`/api/galerie/${selectedImage.id}`"
|
||||
:alt="selectedImage.title"
|
||||
class="max-w-[90%] max-h-[90vh] object-contain mx-auto"
|
||||
/>
|
||||
<div class="mt-4 text-white text-center">
|
||||
<h3 class="text-xl font-semibold">{{ selectedImage.title }}</h3>
|
||||
<p v-if="selectedImage.description" class="mt-2 text-gray-300">
|
||||
{{ selectedImage.description }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-400">
|
||||
Bild {{ currentImageIndex + 1 }} von {{ images.length }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const images = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedImage = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const uploadError = ref('')
|
||||
const uploadSuccess = ref('')
|
||||
const showUploadForm = ref(false)
|
||||
const deleting = ref(null)
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
const uploadForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
isPublic: false
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const isVorstand = computed(() => authStore.user?.role === 'vorstand')
|
||||
|
||||
useHead({
|
||||
title: 'Galerie - Harheimer TC',
|
||||
})
|
||||
|
||||
async function loadImages(page = 1) {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await $fetch(`/api/galerie/list?page=${page}`)
|
||||
images.value = response.images || []
|
||||
if (response.pagination) {
|
||||
pagination.value = response.pagination
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Bilder:', error)
|
||||
images.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(newPage) {
|
||||
if (newPage >= 1 && newPage <= pagination.value.totalPages) {
|
||||
loadImages(newPage)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteImage(imageId) {
|
||||
if (!confirm('Möchten Sie dieses Bild wirklich löschen?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
deleting.value = imageId
|
||||
await $fetch(`/api/galerie/${imageId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
// Galerie neu laden (bleibt auf aktueller Seite)
|
||||
await loadImages(pagination.value.page)
|
||||
} catch (error) {
|
||||
alert(error.data?.message || 'Fehler beim Löschen des Bildes')
|
||||
} finally {
|
||||
deleting.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event) {
|
||||
const file = event.target.files[0]
|
||||
if (file && !uploadForm.value.title) {
|
||||
uploadForm.value.title = file.name.replace(/\.[^/.]+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage() {
|
||||
if (!fileInput.value?.files[0]) {
|
||||
uploadError.value = 'Bitte wählen Sie eine Bilddatei aus'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
uploading.value = true
|
||||
uploadError.value = ''
|
||||
uploadSuccess.value = ''
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('image', fileInput.value.files[0])
|
||||
formData.append('title', uploadForm.value.title)
|
||||
formData.append('description', uploadForm.value.description)
|
||||
formData.append('isPublic', uploadForm.value.isPublic)
|
||||
|
||||
await $fetch('/api/galerie/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
uploadSuccess.value = 'Bild erfolgreich hochgeladen!'
|
||||
uploadForm.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
isPublic: false
|
||||
}
|
||||
fileInput.value.value = ''
|
||||
|
||||
// Galerie neu laden
|
||||
await loadImages(pagination.value.page)
|
||||
|
||||
setTimeout(() => {
|
||||
uploadSuccess.value = ''
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
uploadError.value = error.data?.message || 'Fehler beim Hochladen des Bildes'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const currentImageIndex = computed(() => {
|
||||
if (!selectedImage.value) return -1
|
||||
return images.value.findIndex(img => img.id === selectedImage.value.id)
|
||||
})
|
||||
|
||||
const hasPreviousImage = computed(() => {
|
||||
return currentImageIndex.value > 0
|
||||
})
|
||||
|
||||
const hasNextImage = computed(() => {
|
||||
return currentImageIndex.value >= 0 && currentImageIndex.value < images.value.length - 1
|
||||
})
|
||||
|
||||
function openModal(image) {
|
||||
selectedImage.value = image
|
||||
document.body.style.overflow = 'hidden'
|
||||
// Fokus auf Modal setzen für Keyboard-Navigation
|
||||
setTimeout(() => {
|
||||
const modal = document.querySelector('[tabindex="0"]')
|
||||
if (modal) modal.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
selectedImage.value = null
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
function showPreviousImage() {
|
||||
if (hasPreviousImage.value) {
|
||||
const prevIndex = currentImageIndex.value - 1
|
||||
selectedImage.value = images.value[prevIndex]
|
||||
}
|
||||
}
|
||||
|
||||
function showNextImage() {
|
||||
if (hasNextImage.value) {
|
||||
const nextIndex = currentImageIndex.value + 1
|
||||
selectedImage.value = images.value[nextIndex]
|
||||
}
|
||||
}
|
||||
|
||||
function handleLightboxKeydown(event) {
|
||||
// Nur verarbeiten, wenn Lightbox offen ist
|
||||
if (!selectedImage.value) return
|
||||
|
||||
if (event.key === 'ArrowLeft' && hasPreviousImage.value) {
|
||||
event.preventDefault()
|
||||
showPreviousImage()
|
||||
} else if (event.key === 'ArrowRight' && hasNextImage.value) {
|
||||
event.preventDefault()
|
||||
showNextImage()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
authStore.checkAuth()
|
||||
loadImages()
|
||||
// Keyboard-Event-Listener für Lightbox
|
||||
document.addEventListener('keydown', handleLightboxKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleLightboxKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user