Enhance Vereinsmeisterschaften and Vorstand pages with image support for players and board members. Implement lightbox functionality for player images in Vereinsmeisterschaften. Update CSV handling to include image filenames for better data management. Refactor components to utilize PersonCard for board members, improving code readability and maintainability.
This commit is contained in:
1
backups/users-1766060412221/users.json
Normal file
1
backups/users-1766060412221/users.json
Normal file
@@ -0,0 +1 @@
|
||||
vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ==
|
||||
143
components/ImageUpload.vue
Normal file
143
components/ImageUpload.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ label }}
|
||||
<span v-if="!required" class="text-gray-500 text-xs">(optional)</span>
|
||||
</label>
|
||||
|
||||
<div v-if="imageFilename" class="mb-2">
|
||||
<div class="relative inline-block">
|
||||
<img
|
||||
:src="`/api/personen/${imageFilename}?width=100&height=100`"
|
||||
:alt="label"
|
||||
class="w-24 h-24 object-cover rounded-lg border-2 border-gray-300"
|
||||
/>
|
||||
<button
|
||||
v-if="!uploading"
|
||||
@click="removeImage"
|
||||
class="absolute -top-2 -right-2 bg-red-600 text-white rounded-full p-1 hover:bg-red-700 transition-colors"
|
||||
type="button"
|
||||
title="Bild entfernen"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 border-2 border-dashed rounded-lg cursor-pointer transition-colors',
|
||||
uploading ? 'border-gray-300 bg-gray-50 cursor-not-allowed' :
|
||||
dragOver ? 'border-primary-500 bg-primary-50' :
|
||||
'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||
class="hidden"
|
||||
:disabled="uploading"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<div v-if="uploading" class="flex items-center justify-center gap-2 text-gray-600">
|
||||
<Loader2 :size="16" class="animate-spin" />
|
||||
<span>Wird hochgeladen...</span>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-600">
|
||||
<span v-if="!imageFilename">📷 Bild auswählen oder hier ablegen</span>
|
||||
<span v-else>🔄 Bild ändern</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-1 text-sm text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Loader2, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Bild'
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const dragOver = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const imageFilename = computed(() => props.modelValue)
|
||||
|
||||
async function handleFileSelect(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
await uploadImage(file)
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
if (!file.type.match(/^image\/(jpeg|jpg|png|gif|webp)$/)) {
|
||||
error.value = 'Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)'
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
error.value = 'Bild darf maximal 10MB groß sein'
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch('/api/personen/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ statusMessage: 'Fehler beim Hochladen' }))
|
||||
throw new Error(errorData.statusMessage || 'Fehler beim Hochladen')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
emit('update:modelValue', data.filename)
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Fehler beim Hochladen des Bildes'
|
||||
console.error('Upload error:', err)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
emit('update:modelValue', null)
|
||||
error.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
35
components/PersonCard.vue
Normal file
35
components/PersonCard.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<div v-if="imageFilename" class="mb-4 flex justify-center">
|
||||
<img
|
||||
:src="`/api/personen/${imageFilename}?width=200&height=200`"
|
||||
:alt="`${title}: ${name}`"
|
||||
class="w-32 h-32 object-cover rounded-full border-4 border-primary-100 shadow-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<h3 v-if="title" class="text-xl font-display font-bold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">{{ name }}</h4>
|
||||
<div class="space-y-1 text-gray-600">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
imageFilename: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -295,6 +295,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<ImageUpload
|
||||
v-model="trainer.imageFilename"
|
||||
label="Foto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,6 +465,12 @@
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<ImageUpload
|
||||
v-model="position.imageFilename"
|
||||
label="Foto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,9 +123,28 @@
|
||||
<span class="w-8 h-8 bg-primary-600 text-white rounded-full flex items-center justify-center text-sm font-bold">
|
||||
{{ result.platz }}
|
||||
</span>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="result.imageFilename1" class="flex-shrink-0">
|
||||
<img
|
||||
:src="`/api/personen/${result.imageFilename1}?width=32&height=32`"
|
||||
:alt="result.spieler1"
|
||||
class="w-8 h-8 rounded-full object-cover border border-gray-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900">{{ result.spieler1 }}</span>
|
||||
<span v-if="result.spieler2" class="text-gray-600"> & {{ result.spieler2 }}</span>
|
||||
<span v-if="result.spieler2" class="text-gray-600 flex items-center gap-2">
|
||||
&
|
||||
<div v-if="result.imageFilename2" class="flex-shrink-0">
|
||||
<img
|
||||
:src="`/api/personen/${result.imageFilename2}?width=32&height=32`"
|
||||
:alt="result.spieler2"
|
||||
class="w-8 h-8 rounded-full object-cover border border-gray-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{{ result.spieler2 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
@@ -170,12 +189,15 @@
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||
{{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }}
|
||||
</h3>
|
||||
<div class="bg-white rounded-lg max-w-md w-full max-h-[90vh] flex flex-col">
|
||||
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
{{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveResult" class="space-y-4">
|
||||
<div class="overflow-y-auto flex-1 p-6">
|
||||
<form id="result-form" @submit.prevent="saveResult" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Jahr</label>
|
||||
<input
|
||||
@@ -236,6 +258,20 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ImageUpload
|
||||
v-model="formData.imageFilename1"
|
||||
label="Foto Spieler 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.kategorie === 'Doppel' || formData.kategorie === 'Mixed'">
|
||||
<ImageUpload
|
||||
v-model="formData.imageFilename2"
|
||||
label="Foto Spieler 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Bemerkung (optional)</label>
|
||||
<textarea
|
||||
@@ -245,22 +281,25 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-200 flex-shrink-0 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="result-form"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +369,9 @@ const formData = ref({
|
||||
platz: '',
|
||||
spieler1: '',
|
||||
spieler2: '',
|
||||
bemerkung: ''
|
||||
bemerkung: '',
|
||||
imageFilename1: '',
|
||||
imageFilename2: ''
|
||||
})
|
||||
|
||||
const loadResults = async () => {
|
||||
@@ -370,6 +411,7 @@ const loadResults = async () => {
|
||||
}
|
||||
values.push(current.trim())
|
||||
|
||||
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
|
||||
if (values.length < 6) return null
|
||||
|
||||
return {
|
||||
@@ -378,7 +420,9 @@ const loadResults = async () => {
|
||||
platz: values[2].trim(),
|
||||
spieler1: values[3].trim(),
|
||||
spieler2: values[4].trim(),
|
||||
bemerkung: values[5].trim()
|
||||
bemerkung: values[5].trim(),
|
||||
imageFilename1: values[6]?.trim() || '',
|
||||
imageFilename2: values[7]?.trim() || ''
|
||||
}
|
||||
}).filter(result => result !== null)
|
||||
} catch (error) {
|
||||
@@ -445,7 +489,9 @@ const addNewResult = () => {
|
||||
platz: '',
|
||||
spieler1: '',
|
||||
spieler2: '',
|
||||
bemerkung: ''
|
||||
bemerkung: '',
|
||||
imageFilename1: '',
|
||||
imageFilename2: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -461,7 +507,9 @@ const addResultForYear = (jahr) => {
|
||||
platz: '',
|
||||
spieler1: '',
|
||||
spieler2: '',
|
||||
bemerkung: ''
|
||||
bemerkung: '',
|
||||
imageFilename1: '',
|
||||
imageFilename2: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -477,7 +525,9 @@ const addResultForKategorie = (jahr, kategorie) => {
|
||||
platz: '',
|
||||
spieler1: '',
|
||||
spieler2: '',
|
||||
bemerkung: ''
|
||||
bemerkung: '',
|
||||
imageFilename1: '',
|
||||
imageFilename2: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -493,7 +543,9 @@ const editResult = (result, jahr, kategorie, index) => {
|
||||
platz: result.platz,
|
||||
spieler1: result.spieler1,
|
||||
spieler2: result.spieler2,
|
||||
bemerkung: result.bemerkung
|
||||
bemerkung: result.bemerkung,
|
||||
imageFilename1: result.imageFilename1 || '',
|
||||
imageFilename2: result.imageFilename2 || ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -649,7 +701,7 @@ const closeBemerkungModal = () => {
|
||||
const save = async () => {
|
||||
try {
|
||||
// CSV generieren
|
||||
const csvHeader = 'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung'
|
||||
const csvHeader = 'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2'
|
||||
const csvRows = results.value.map(result => {
|
||||
return [
|
||||
result.jahr,
|
||||
@@ -657,7 +709,9 @@ const save = async () => {
|
||||
result.platz,
|
||||
result.spieler1,
|
||||
result.spieler2,
|
||||
result.bemerkung
|
||||
result.bemerkung,
|
||||
result.imageFilename1 || '',
|
||||
result.imageFilename2 || ''
|
||||
].map(field => `"${field}"`).join(',')
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
:key="trainer.id"
|
||||
class="bg-white p-8 rounded-xl shadow-lg"
|
||||
>
|
||||
<div v-if="trainer.imageFilename" class="mb-4 flex justify-center">
|
||||
<img
|
||||
:src="`/api/personen/${trainer.imageFilename}?width=200&height=200`"
|
||||
:alt="trainer.name"
|
||||
class="w-32 h-32 object-cover rounded-full border-4 border-primary-100 shadow-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">{{ trainer.lizenz }}</h3>
|
||||
<p class="text-gray-600 mb-4">{{ trainer.name }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
|
||||
@@ -88,13 +88,37 @@
|
||||
>
|
||||
{{ ergebnis.platz }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold text-gray-900">
|
||||
{{ ergebnis.spieler1 }}
|
||||
<span v-if="ergebnis.spieler2" class="text-gray-600">
|
||||
/ {{ ergebnis.spieler2 }}
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="ergebnis.imageFilename1" class="flex-shrink-0">
|
||||
<img
|
||||
:src="`/api/personen/${ergebnis.imageFilename1}?width=40&height=40`"
|
||||
:alt="ergebnis.spieler1"
|
||||
class="w-10 h-10 rounded-full object-cover border-2 border-gray-300 cursor-pointer hover:border-primary-500 transition-colors"
|
||||
loading="lazy"
|
||||
@click="openLightbox(ergebnis.imageFilename1, ergebnis.spieler1)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold text-gray-900">
|
||||
{{ ergebnis.spieler1 }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="ergebnis.spieler2" class="text-gray-600">
|
||||
<span v-if="ergebnis.imageFilename2" class="ml-2 inline-flex items-center gap-2">
|
||||
/
|
||||
<img
|
||||
:src="`/api/personen/${ergebnis.imageFilename2}?width=40&height=40`"
|
||||
:alt="ergebnis.spieler2"
|
||||
class="w-10 h-10 rounded-full object-cover border-2 border-gray-300 cursor-pointer hover:border-primary-500 transition-colors"
|
||||
loading="lazy"
|
||||
@click="openLightbox(ergebnis.imageFilename2, ergebnis.spieler2)"
|
||||
/>
|
||||
{{ ergebnis.spieler2 }}
|
||||
</span>
|
||||
<span v-else class="text-gray-600">
|
||||
/ {{ ergebnis.spieler2 }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
@@ -147,6 +171,37 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox für Bilder -->
|
||||
<div
|
||||
v-if="lightboxImage"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
|
||||
@click="closeLightbox"
|
||||
tabindex="0"
|
||||
@keydown="handleLightboxKeydown"
|
||||
>
|
||||
<div class="relative max-w-5xl max-h-full" @click.stop>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="closeLightbox"
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10 bg-black bg-opacity-50 rounded-full p-3"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<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>
|
||||
|
||||
<img
|
||||
:src="`/api/personen/${lightboxImage.filename}`"
|
||||
:alt="lightboxImage.name"
|
||||
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">{{ lightboxImage.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -156,6 +211,7 @@ import { Trophy } from 'lucide-vue-next'
|
||||
|
||||
const results = ref([])
|
||||
const selectedYear = ref('alle')
|
||||
const lightboxImage = ref(null)
|
||||
|
||||
const loadResults = async () => {
|
||||
try {
|
||||
@@ -188,6 +244,7 @@ const loadResults = async () => {
|
||||
}
|
||||
values.push(current.trim())
|
||||
|
||||
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
|
||||
if (values.length < 6) return null
|
||||
|
||||
return {
|
||||
@@ -196,7 +253,9 @@ const loadResults = async () => {
|
||||
platz: values[2].trim(),
|
||||
spieler1: values[3].trim(),
|
||||
spieler2: values[4].trim(),
|
||||
bemerkung: values[5].trim()
|
||||
bemerkung: values[5].trim(),
|
||||
imageFilename1: values[6]?.trim() || '',
|
||||
imageFilename2: values[7]?.trim() || ''
|
||||
}
|
||||
}).filter(result => result !== null)
|
||||
} catch (error) {
|
||||
@@ -268,6 +327,26 @@ const totalDoubles = computed(() => {
|
||||
return results.value.filter(r => r.kategorie === 'Doppel' && r.platz === '1').length
|
||||
})
|
||||
|
||||
function openLightbox(filename, name) {
|
||||
lightboxImage.value = { filename, name }
|
||||
document.body.style.overflow = 'hidden'
|
||||
setTimeout(() => {
|
||||
const modal = document.querySelector('[tabindex="0"]')
|
||||
if (modal) modal.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxImage.value = null
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
function handleLightboxKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeLightbox()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadResults()
|
||||
})
|
||||
|
||||
@@ -13,112 +13,106 @@
|
||||
|
||||
<div v-if="config" class="grid md:grid-cols-2 gap-8 not-prose">
|
||||
<!-- Vorsitzender -->
|
||||
<div v-if="config.vorstand.vorsitzender.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Vorsitzender</h3>
|
||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
||||
{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }}
|
||||
</h4>
|
||||
<div class="space-y-1 text-gray-600">
|
||||
<p v-if="config.vorstand.vorsitzender.strasse">{{ config.vorstand.vorsitzender.strasse }}</p>
|
||||
<p v-if="config.vorstand.vorsitzender.plz">{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}</p>
|
||||
<p v-if="config.vorstand.vorsitzender.telefon">Tel. {{ config.vorstand.vorsitzender.telefon }}</p>
|
||||
<p v-if="config.vorstand.vorsitzender.email">
|
||||
<a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.vorsitzender.email }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PersonCard
|
||||
v-if="config.vorstand.vorsitzender.vorname"
|
||||
title="Vorsitzender"
|
||||
:name="`${config.vorstand.vorsitzender.vorname} ${config.vorstand.vorsitzender.nachname}`"
|
||||
:image-filename="config.vorstand.vorsitzender.imageFilename"
|
||||
>
|
||||
<p v-if="config.vorstand.vorsitzender.strasse">{{ config.vorstand.vorsitzender.strasse }}</p>
|
||||
<p v-if="config.vorstand.vorsitzender.plz">{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}</p>
|
||||
<p v-if="config.vorstand.vorsitzender.telefon">Tel. {{ config.vorstand.vorsitzender.telefon }}</p>
|
||||
<p v-if="config.vorstand.vorsitzender.email">
|
||||
<a :href="`mailto:${config.vorstand.vorsitzender.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.vorsitzender.email }}
|
||||
</a>
|
||||
</p>
|
||||
</PersonCard>
|
||||
|
||||
<!-- Stellvertreter -->
|
||||
<div v-if="config.vorstand.stellvertreter.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Stellvertreter</h3>
|
||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
||||
{{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }}
|
||||
</h4>
|
||||
<div class="space-y-1 text-gray-600">
|
||||
<p v-if="config.vorstand.stellvertreter.strasse">{{ config.vorstand.stellvertreter.strasse }}</p>
|
||||
<p v-if="config.vorstand.stellvertreter.plz">{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}</p>
|
||||
<p v-if="config.vorstand.stellvertreter.telefon">Tel. {{ config.vorstand.stellvertreter.telefon }}</p>
|
||||
<p v-if="config.vorstand.stellvertreter.email">
|
||||
<a :href="`mailto:${config.vorstand.stellvertreter.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.stellvertreter.email }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PersonCard
|
||||
v-if="config.vorstand.stellvertreter.vorname"
|
||||
title="Stellvertreter"
|
||||
:name="`${config.vorstand.stellvertreter.vorname} ${config.vorstand.stellvertreter.nachname}`"
|
||||
:image-filename="config.vorstand.stellvertreter.imageFilename"
|
||||
>
|
||||
<p v-if="config.vorstand.stellvertreter.strasse">{{ config.vorstand.stellvertreter.strasse }}</p>
|
||||
<p v-if="config.vorstand.stellvertreter.plz">{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}</p>
|
||||
<p v-if="config.vorstand.stellvertreter.telefon">Tel. {{ config.vorstand.stellvertreter.telefon }}</p>
|
||||
<p v-if="config.vorstand.stellvertreter.email">
|
||||
<a :href="`mailto:${config.vorstand.stellvertreter.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.stellvertreter.email }}
|
||||
</a>
|
||||
</p>
|
||||
</PersonCard>
|
||||
|
||||
<!-- Kassenwart -->
|
||||
<div v-if="config.vorstand.kassenwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Kassenwart</h3>
|
||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
||||
{{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }}
|
||||
</h4>
|
||||
<div class="space-y-1 text-gray-600">
|
||||
<p v-if="config.vorstand.kassenwart.strasse">{{ config.vorstand.kassenwart.strasse }}</p>
|
||||
<p v-if="config.vorstand.kassenwart.plz">{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}</p>
|
||||
<p v-if="config.vorstand.kassenwart.telefon">Tel. {{ config.vorstand.kassenwart.telefon }}</p>
|
||||
<p v-if="config.vorstand.kassenwart.email">
|
||||
<a :href="`mailto:${config.vorstand.kassenwart.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.kassenwart.email }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PersonCard
|
||||
v-if="config.vorstand.kassenwart.vorname"
|
||||
title="Kassenwart"
|
||||
:name="`${config.vorstand.kassenwart.vorname} ${config.vorstand.kassenwart.nachname}`"
|
||||
:image-filename="config.vorstand.kassenwart.imageFilename"
|
||||
>
|
||||
<p v-if="config.vorstand.kassenwart.strasse">{{ config.vorstand.kassenwart.strasse }}</p>
|
||||
<p v-if="config.vorstand.kassenwart.plz">{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}</p>
|
||||
<p v-if="config.vorstand.kassenwart.telefon">Tel. {{ config.vorstand.kassenwart.telefon }}</p>
|
||||
<p v-if="config.vorstand.kassenwart.email">
|
||||
<a :href="`mailto:${config.vorstand.kassenwart.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.kassenwart.email }}
|
||||
</a>
|
||||
</p>
|
||||
</PersonCard>
|
||||
|
||||
<!-- Schriftführer -->
|
||||
<div v-if="config.vorstand.schriftfuehrer.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Schriftführer</h3>
|
||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
||||
{{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }}
|
||||
</h4>
|
||||
<div class="space-y-1 text-gray-600">
|
||||
<p v-if="config.vorstand.schriftfuehrer.strasse">{{ config.vorstand.schriftfuehrer.strasse }}</p>
|
||||
<p v-if="config.vorstand.schriftfuehrer.plz">{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}</p>
|
||||
<p v-if="config.vorstand.schriftfuehrer.telefon">Tel. {{ config.vorstand.schriftfuehrer.telefon }}</p>
|
||||
<p v-if="config.vorstand.schriftfuehrer.email">
|
||||
<a :href="`mailto:${config.vorstand.schriftfuehrer.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.schriftfuehrer.email }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PersonCard
|
||||
v-if="config.vorstand.schriftfuehrer.vorname"
|
||||
title="Schriftführer"
|
||||
:name="`${config.vorstand.schriftfuehrer.vorname} ${config.vorstand.schriftfuehrer.nachname}`"
|
||||
:image-filename="config.vorstand.schriftfuehrer.imageFilename"
|
||||
>
|
||||
<p v-if="config.vorstand.schriftfuehrer.strasse">{{ config.vorstand.schriftfuehrer.strasse }}</p>
|
||||
<p v-if="config.vorstand.schriftfuehrer.plz">{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}</p>
|
||||
<p v-if="config.vorstand.schriftfuehrer.telefon">Tel. {{ config.vorstand.schriftfuehrer.telefon }}</p>
|
||||
<p v-if="config.vorstand.schriftfuehrer.email">
|
||||
<a :href="`mailto:${config.vorstand.schriftfuehrer.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.schriftfuehrer.email }}
|
||||
</a>
|
||||
</p>
|
||||
</PersonCard>
|
||||
|
||||
<!-- Sportwart -->
|
||||
<div v-if="config.vorstand.sportwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Sportwart</h3>
|
||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
||||
{{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }}
|
||||
</h4>
|
||||
<div class="space-y-1 text-gray-600">
|
||||
<p v-if="config.vorstand.sportwart.strasse">{{ config.vorstand.sportwart.strasse }}</p>
|
||||
<p v-if="config.vorstand.sportwart.plz">{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}</p>
|
||||
<p v-if="config.vorstand.sportwart.telefon">Tel. {{ config.vorstand.sportwart.telefon }}</p>
|
||||
<p v-if="config.vorstand.sportwart.email">
|
||||
<a :href="`mailto:${config.vorstand.sportwart.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.sportwart.email }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PersonCard
|
||||
v-if="config.vorstand.sportwart.vorname"
|
||||
title="Sportwart"
|
||||
:name="`${config.vorstand.sportwart.vorname} ${config.vorstand.sportwart.nachname}`"
|
||||
:image-filename="config.vorstand.sportwart.imageFilename"
|
||||
>
|
||||
<p v-if="config.vorstand.sportwart.strasse">{{ config.vorstand.sportwart.strasse }}</p>
|
||||
<p v-if="config.vorstand.sportwart.plz">{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}</p>
|
||||
<p v-if="config.vorstand.sportwart.telefon">Tel. {{ config.vorstand.sportwart.telefon }}</p>
|
||||
<p v-if="config.vorstand.sportwart.email">
|
||||
<a :href="`mailto:${config.vorstand.sportwart.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.sportwart.email }}
|
||||
</a>
|
||||
</p>
|
||||
</PersonCard>
|
||||
|
||||
<!-- Jugendwart -->
|
||||
<div v-if="config.vorstand.jugendwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Jugendwart</h3>
|
||||
<h4 class="text-lg font-semibold text-primary-600 mb-3">
|
||||
{{ config.vorstand.jugendwart.vorname }} {{ config.vorstand.jugendwart.nachname }}
|
||||
</h4>
|
||||
<div class="space-y-1 text-gray-600">
|
||||
<p v-if="config.vorstand.jugendwart.strasse">{{ config.vorstand.jugendwart.strasse }}</p>
|
||||
<p v-if="config.vorstand.jugendwart.plz">{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}</p>
|
||||
<p v-if="config.vorstand.jugendwart.telefon">Tel. {{ config.vorstand.jugendwart.telefon }}</p>
|
||||
<p v-if="config.vorstand.jugendwart.email">
|
||||
<a :href="`mailto:${config.vorstand.jugendwart.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.jugendwart.email }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PersonCard
|
||||
v-if="config.vorstand.jugendwart.vorname"
|
||||
title="Jugendwart"
|
||||
:name="`${config.vorstand.jugendwart.vorname} ${config.vorstand.jugendwart.nachname}`"
|
||||
:image-filename="config.vorstand.jugendwart.imageFilename"
|
||||
>
|
||||
<p v-if="config.vorstand.jugendwart.strasse">{{ config.vorstand.jugendwart.strasse }}</p>
|
||||
<p v-if="config.vorstand.jugendwart.plz">{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}</p>
|
||||
<p v-if="config.vorstand.jugendwart.telefon">Tel. {{ config.vorstand.jugendwart.telefon }}</p>
|
||||
<p v-if="config.vorstand.jugendwart.email">
|
||||
<a :href="`mailto:${config.vorstand.jugendwart.email}`" class="text-primary-600 hover:underline">
|
||||
{{ config.vorstand.jugendwart.email }}
|
||||
</a>
|
||||
</p>
|
||||
</PersonCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung
|
||||
"2024","Einzel","1","Michael Koch","",""
|
||||
"2024","Einzel","2","Olaf Nüßlein","",""
|
||||
"2024","Einzel","3","Bernd Meyer","",""
|
||||
"2024","Doppel","1","Sven Baublies","Johannes Binder",""
|
||||
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann",""
|
||||
"2024","Doppel","3","Michael Koch","Jacob Waltenberger",""
|
||||
"2023","Einzel","1","André Gilzinger","",""
|
||||
"2023","Einzel","2","Olaf Nüßlein","",""
|
||||
"2023","Einzel","3","Michael Koch","",""
|
||||
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder",""
|
||||
"2023","Doppel","2","Renate Nebel","André Gilzinger",""
|
||||
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz",""
|
||||
"2022","Einzel","1","Sven Baublies","",""
|
||||
"2022","Einzel","2","Thomas Steinbrech","",""
|
||||
"2022","Einzel","3","André Gilzinger","",""
|
||||
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt",""
|
||||
"2022","Doppel","2","Michael Weber","Johannes Binder",""
|
||||
"2022","Doppel","3","Michael Koch","Renate Nebel",""
|
||||
"2021","","","","","coronabedingter Ausfall"
|
||||
"2020","","","","","coronabedingter Ausfall"
|
||||
"2019","Einzel","1","André Gilzinger","",""
|
||||
"2019","Einzel","2","Thomas Steinbrech","",""
|
||||
"2019","Einzel","3","Jürgen Kratz","",""
|
||||
"2019","Doppel","1","André Gilzinger","Volker Marx",""
|
||||
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau",""
|
||||
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt",""
|
||||
"2018","Einzel","1","André Gilzinger","",""
|
||||
"2018","Einzel","2","Jürgen Kratz","",""
|
||||
"2018","Einzel","3","Sven Baublies","",""
|
||||
"2018","Doppel","1","André Gilzinger","Volker Marx",""
|
||||
"2018","Doppel","2","Sven Baublies","Helge Stefan",""
|
||||
"2018","Doppel","3","Jürgen Kratz","Renate Nebel",""
|
||||
"2017","Einzel","1","André Gilzinger","",""
|
||||
"2017","Einzel","2","Sven Baublies","",""
|
||||
"2017","Einzel","3","Olaf Nüßlein","",""
|
||||
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan",""
|
||||
"2017","Doppel","2","André Gilzinger","Renate Nebel",""
|
||||
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt",""
|
||||
"2016","Herren-Einzel","1","André Gilzinger","",""
|
||||
"2016","Herren-Einzel","2","Sven Baublies","",""
|
||||
"2016","Herren-Einzel","3","Olaf Nüßlein","",""
|
||||
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","",""
|
||||
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","",""
|
||||
"2016","Damen-Einzel","3","Renate Nebel","",""
|
||||
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt",""
|
||||
"2016","Doppel","2","André Gilzinger","Bernd Meyer",""
|
||||
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi",""
|
||||
"2025","Doppel","1","a","b",""
|
||||
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2
|
||||
"2024","Einzel","1","Michael Koch","","","",""
|
||||
"2024","Einzel","2","Olaf Nüßlein","","","",""
|
||||
"2024","Einzel","3","Bernd Meyer","","","",""
|
||||
"2024","Doppel","1","Sven Baublies","Johannes Binder","","",""
|
||||
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann","","",""
|
||||
"2024","Doppel","3","Michael Koch","Jacob Waltenberger","","",""
|
||||
"2023","Einzel","1","André Gilzinger","","","",""
|
||||
"2023","Einzel","2","Olaf Nüßlein","","","",""
|
||||
"2023","Einzel","3","Michael Koch","","","",""
|
||||
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder","","",""
|
||||
"2023","Doppel","2","Renate Nebel","André Gilzinger","","",""
|
||||
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz","","",""
|
||||
"2022","Einzel","1","Sven Baublies","","","",""
|
||||
"2022","Einzel","2","Thomas Steinbrech","","","",""
|
||||
"2022","Einzel","3","André Gilzinger","","","",""
|
||||
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt","","",""
|
||||
"2022","Doppel","2","Michael Weber","Johannes Binder","","",""
|
||||
"2022","Doppel","3","Michael Koch","Renate Nebel","","",""
|
||||
"2021","","","","","coronabedingter Ausfall","",""
|
||||
"2020","","","","","coronabedingter Ausfall","",""
|
||||
"2019","Einzel","1","André Gilzinger","","","",""
|
||||
"2019","Einzel","2","Thomas Steinbrech","","","",""
|
||||
"2019","Einzel","3","Jürgen Kratz","","","",""
|
||||
"2019","Doppel","1","André Gilzinger","Volker Marx","","",""
|
||||
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau","","",""
|
||||
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt","","",""
|
||||
"2018","Einzel","1","André Gilzinger","","","",""
|
||||
"2018","Einzel","2","Jürgen Kratz","","","",""
|
||||
"2018","Einzel","3","Sven Baublies","","","",""
|
||||
"2018","Doppel","1","André Gilzinger","Volker Marx","","",""
|
||||
"2018","Doppel","2","Sven Baublies","Helge Stefan","","",""
|
||||
"2018","Doppel","3","Jürgen Kratz","Renate Nebel","","",""
|
||||
"2017","Einzel","1","André Gilzinger","","","",""
|
||||
"2017","Einzel","2","Sven Baublies","","","",""
|
||||
"2017","Einzel","3","Olaf Nüßlein","","","",""
|
||||
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan","","",""
|
||||
"2017","Doppel","2","André Gilzinger","Renate Nebel","","",""
|
||||
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt","","",""
|
||||
"2016","Herren-Einzel","1","André Gilzinger","","","",""
|
||||
"2016","Herren-Einzel","2","Sven Baublies","","","",""
|
||||
"2016","Herren-Einzel","3","Olaf Nüßlein","","","",""
|
||||
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","","","",""
|
||||
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","","","",""
|
||||
"2016","Damen-Einzel","3","Renate Nebel","","","",""
|
||||
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt","","",""
|
||||
"2016","Doppel","2","André Gilzinger","Bernd Meyer","","",""
|
||||
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi","","",""
|
||||
"2025","Doppel","1","a","b","","7f6c46f8-b93f-4807-b369-b26e0bba2da5.png","4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png"
|
||||
|
108
server/api/personen/[filename].get.js
Normal file
108
server/api/personen/[filename].get.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import sharp from 'sharp'
|
||||
|
||||
// Handle both dev and production paths
|
||||
const getDataPath = (filename) => {
|
||||
const cwd = process.cwd()
|
||||
if (cwd.endsWith('.output')) {
|
||||
return path.join(cwd, '../server/data', filename)
|
||||
}
|
||||
return path.join(cwd, 'server/data', filename)
|
||||
}
|
||||
|
||||
const PERSONEN_DIR = getDataPath('personen')
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const filename = getRouterParam(event, 'filename')
|
||||
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Dateiname erforderlich'
|
||||
})
|
||||
}
|
||||
|
||||
// Sicherheitsprüfung: Nur erlaubte Dateinamen (UUID-Format)
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|jpeg|png|gif|webp)$/i.test(filename)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Ungültiger Dateiname'
|
||||
})
|
||||
}
|
||||
|
||||
const filePath = path.join(PERSONEN_DIR, filename)
|
||||
|
||||
// Prüfe ob Datei existiert
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Bild nicht gefunden'
|
||||
})
|
||||
}
|
||||
|
||||
// MIME-Type bestimmen
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
const mimeTypes = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||
|
||||
// Optional: Query-Parameter für Größe
|
||||
const query = getQuery(event)
|
||||
const width = query.width ? parseInt(query.width) : null
|
||||
const height = query.height ? parseInt(query.height) : null
|
||||
|
||||
let imageBuffer = await fs.readFile(filePath)
|
||||
|
||||
// Bild verarbeiten falls Größe angegeben
|
||||
if (width || height) {
|
||||
const resizeOptions = {}
|
||||
if (width && height) {
|
||||
resizeOptions.width = width
|
||||
resizeOptions.height = height
|
||||
resizeOptions.fit = 'cover'
|
||||
} else if (width) {
|
||||
resizeOptions.width = width
|
||||
resizeOptions.fit = 'inside'
|
||||
resizeOptions.withoutEnlargement = true
|
||||
} else if (height) {
|
||||
resizeOptions.height = height
|
||||
resizeOptions.fit = 'inside'
|
||||
resizeOptions.withoutEnlargement = true
|
||||
}
|
||||
|
||||
imageBuffer = await sharp(imageBuffer)
|
||||
.rotate() // EXIF-Orientierung korrigieren
|
||||
.resize(resizeOptions)
|
||||
.toBuffer()
|
||||
} else {
|
||||
// Nur EXIF-Orientierung korrigieren
|
||||
imageBuffer = await sharp(imageBuffer).rotate().toBuffer()
|
||||
}
|
||||
|
||||
setHeader(event, 'Content-Type', contentType)
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=31536000')
|
||||
|
||||
return imageBuffer
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Personenfotos:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Fehler beim Laden des Bildes'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
127
server/api/personen/upload.post.js
Normal file
127
server/api/personen/upload.post.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import multer from 'multer'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import sharp from 'sharp'
|
||||
import { getUserFromToken, verifyToken } from '../../utils/auth.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
// Handle both dev and production paths
|
||||
const getDataPath = (filename) => {
|
||||
const cwd = process.cwd()
|
||||
if (cwd.endsWith('.output')) {
|
||||
return path.join(cwd, '../server/data', filename)
|
||||
}
|
||||
return path.join(cwd, 'server/data', filename)
|
||||
}
|
||||
|
||||
const PERSONEN_DIR = getDataPath('personen')
|
||||
|
||||
// Multer-Konfiguration für Bild-Uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
try {
|
||||
await fs.mkdir(PERSONEN_DIR, { recursive: true })
|
||||
cb(null, PERSONEN_DIR)
|
||||
} catch (error) {
|
||||
cb(error)
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname)
|
||||
const filename = `${randomUUID()}${ext}`
|
||||
cb(null, filename)
|
||||
}
|
||||
})
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||
if (allowedMimes.includes(file.mimetype)) {
|
||||
cb(null, true)
|
||||
} else {
|
||||
cb(new Error('Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)'), false)
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB Limit
|
||||
}
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Authentifizierung prüfen
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Nicht authentifiziert'
|
||||
})
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Ungültiges Token'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getUserFromToken(token)
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'vorstand')) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Keine Berechtigung zum Hochladen von Bildern'
|
||||
})
|
||||
}
|
||||
|
||||
// Multer-Middleware für multipart/form-data
|
||||
await new Promise((resolve, reject) => {
|
||||
upload.single('image')(event.node.req, event.node.res, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const file = event.node.req.file
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Keine Bilddatei hochgeladen'
|
||||
})
|
||||
}
|
||||
|
||||
// Bild mit sharp verarbeiten (EXIF-Orientierung korrigieren und optional resize)
|
||||
const originalPath = file.path
|
||||
const ext = path.extname(file.originalname)
|
||||
const newFilename = `${randomUUID()}${ext}`
|
||||
const newPath = path.join(PERSONEN_DIR, newFilename)
|
||||
|
||||
// Bild verarbeiten: EXIF-Orientierung korrigieren
|
||||
await sharp(originalPath)
|
||||
.rotate()
|
||||
.toFile(newPath)
|
||||
|
||||
// Temporäre Datei löschen
|
||||
await fs.unlink(originalPath).catch(() => {})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Bild erfolgreich hochgeladen',
|
||||
filename: newFilename
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Hochladen des Personenfotos:', error)
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Fehler beim Hochladen des Bildes'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"name": "Torsten Schulz",
|
||||
"lizenz": "C-Trainer",
|
||||
"schwerpunkt": "Nachwuchsförderung",
|
||||
"zusatz": "Erwachsenen bei Wunsch zur Verfügung"
|
||||
"zusatz": "Erwachsenen bei Wunsch zur Verfügung",
|
||||
"imageFilename": "8f79a5b9-bfba-43c4-9ab8-81192337bd8f.png"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
@@ -99,7 +100,8 @@
|
||||
"plz": "60437",
|
||||
"ort": "Frankfurt",
|
||||
"telefon": "06101-9953015",
|
||||
"email": "rogerdichmann@gmx.de"
|
||||
"email": "rogerdichmann@gmx.de",
|
||||
"imageFilename": "c24ef84d-2ae4-4edc-be01-063d9917da04.png"
|
||||
},
|
||||
"stellvertreter": {
|
||||
"vorname": "Jürgen",
|
||||
|
||||
@@ -117,5 +117,12 @@
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYyMzMzNDIzLCJleHAiOjE3NjI5MzgyMjN9.V-L5ethO0VFSOPT2qbsQF2zQYQZSlese1rL5sIFaHbY",
|
||||
"createdAt": "2025-11-05T09:03:43.617Z",
|
||||
"expiresAt": "2025-11-12T09:03:43.617Z"
|
||||
},
|
||||
{
|
||||
"id": "1766060415179",
|
||||
"userId": "1766060412277",
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjE3NjYwNjA0MTIyNzciLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzY2MDYwNDE1LCJleHAiOjE3NjY2NjUyMTV9.B-BzHefBmvCZ3qbJ99uN2OMIszcRPJohyj4xknJFExE",
|
||||
"createdAt": "2025-12-18T12:20:15.179Z",
|
||||
"expiresAt": "2025-12-25T12:20:15.179Z"
|
||||
}
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ==
|
||||
3+uWOe4pSXnAFtrdeCqRG+HvbRIsI2HUcMkzrEBlqEmf/9rasPUIv5xhfS+3vh3BJh89fjff0N9l7C8SZbe/ABq75ffwHa1rT72fExEAQ0B/TntBBARNeACYRtx7j3OTJs0+DPiJvraXshqqVjJQjFVMRk1PdmNs3wbZQ9JkXazyne+Gvb6NJWBAeBv4s5pOe3y06GnUO2ZMsGPX3nKdumbRjFXoNzOWtzMQy9m8GYTAQGtC+dMzRTAjKuPxMLLYT1e8hMhJkhbGDOB0+VgOG2o1zGa+eD2ayoHqzUBf5/RZs09rRspXJZ7HKjvgdnkJuO2lstjQeOFtzoljGE9EC2ueRGUuOsyi0AQrbBhVTj3wWIb5V+mNxNccKv9KDs4/EPwyu8l32Ql6kepNuXofZMbJHuwwYvXIvpIj31HdJP0=
|
||||
Reference in New Issue
Block a user