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:
Torsten Schulz (local)
2025-12-18 13:37:03 +01:00
parent a004ffba9b
commit baf6c59c0d
14 changed files with 756 additions and 186 deletions

View 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
View 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
View 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>

View File

@@ -295,6 +295,12 @@
</button> </button>
</div> </div>
</div> </div>
<div class="sm:col-span-2">
<ImageUpload
v-model="trainer.imageFilename"
label="Foto"
/>
</div>
</div> </div>
</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" 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>
<div class="sm:col-span-2">
<ImageUpload
v-model="position.imageFilename"
label="Foto"
/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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"> <span class="w-8 h-8 bg-primary-600 text-white rounded-full flex items-center justify-center text-sm font-bold">
{{ result.platz }} {{ result.platz }}
</span> </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 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> </div>
<div class="flex space-x-2"> <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" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeModal" @click.self="closeModal"
> >
<div class="bg-white rounded-lg max-w-md w-full p-6"> <div class="bg-white rounded-lg max-w-md w-full max-h-[90vh] flex flex-col">
<h3 class="text-lg font-semibold text-gray-900 mb-4"> <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' }} {{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }}
</h3> </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> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Jahr</label> <label class="block text-sm font-medium text-gray-700 mb-2">Jahr</label>
<input <input
@@ -236,6 +258,20 @@
/> />
</div> </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> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Bemerkung (optional)</label> <label class="block text-sm font-medium text-gray-700 mb-2">Bemerkung (optional)</label>
<textarea <textarea
@@ -245,7 +281,10 @@
></textarea> ></textarea>
</div> </div>
<div class="flex justify-end space-x-3 pt-4"> </form>
</div>
<div class="p-6 border-t border-gray-200 flex-shrink-0 flex justify-end space-x-3">
<button <button
type="button" type="button"
@click="closeModal" @click="closeModal"
@@ -255,12 +294,12 @@
</button> </button>
<button <button
type="submit" type="submit"
form="result-form"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
> >
{{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }} {{ editingResult ? 'Aktualisieren' : 'Hinzufügen' }}
</button> </button>
</div> </div>
</form>
</div> </div>
</div> </div>
@@ -330,7 +369,9 @@ const formData = ref({
platz: '', platz: '',
spieler1: '', spieler1: '',
spieler2: '', spieler2: '',
bemerkung: '' bemerkung: '',
imageFilename1: '',
imageFilename2: ''
}) })
const loadResults = async () => { const loadResults = async () => {
@@ -370,6 +411,7 @@ const loadResults = async () => {
} }
values.push(current.trim()) values.push(current.trim())
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
if (values.length < 6) return null if (values.length < 6) return null
return { return {
@@ -378,7 +420,9 @@ const loadResults = async () => {
platz: values[2].trim(), platz: values[2].trim(),
spieler1: values[3].trim(), spieler1: values[3].trim(),
spieler2: values[4].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) }).filter(result => result !== null)
} catch (error) { } catch (error) {
@@ -445,7 +489,9 @@ const addNewResult = () => {
platz: '', platz: '',
spieler1: '', spieler1: '',
spieler2: '', spieler2: '',
bemerkung: '' bemerkung: '',
imageFilename1: '',
imageFilename2: ''
} }
showModal.value = true showModal.value = true
} }
@@ -461,7 +507,9 @@ const addResultForYear = (jahr) => {
platz: '', platz: '',
spieler1: '', spieler1: '',
spieler2: '', spieler2: '',
bemerkung: '' bemerkung: '',
imageFilename1: '',
imageFilename2: ''
} }
showModal.value = true showModal.value = true
} }
@@ -477,7 +525,9 @@ const addResultForKategorie = (jahr, kategorie) => {
platz: '', platz: '',
spieler1: '', spieler1: '',
spieler2: '', spieler2: '',
bemerkung: '' bemerkung: '',
imageFilename1: '',
imageFilename2: ''
} }
showModal.value = true showModal.value = true
} }
@@ -493,7 +543,9 @@ const editResult = (result, jahr, kategorie, index) => {
platz: result.platz, platz: result.platz,
spieler1: result.spieler1, spieler1: result.spieler1,
spieler2: result.spieler2, spieler2: result.spieler2,
bemerkung: result.bemerkung bemerkung: result.bemerkung,
imageFilename1: result.imageFilename1 || '',
imageFilename2: result.imageFilename2 || ''
} }
showModal.value = true showModal.value = true
} }
@@ -649,7 +701,7 @@ const closeBemerkungModal = () => {
const save = async () => { const save = async () => {
try { try {
// CSV generieren // 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 => { const csvRows = results.value.map(result => {
return [ return [
result.jahr, result.jahr,
@@ -657,7 +709,9 @@ const save = async () => {
result.platz, result.platz,
result.spieler1, result.spieler1,
result.spieler2, result.spieler2,
result.bemerkung result.bemerkung,
result.imageFilename1 || '',
result.imageFilename2 || ''
].map(field => `"${field}"`).join(',') ].map(field => `"${field}"`).join(',')
}) })

View File

@@ -16,6 +16,14 @@
:key="trainer.id" :key="trainer.id"
class="bg-white p-8 rounded-xl shadow-lg" 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> <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-gray-600 mb-4">{{ trainer.name }}</p>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">

View File

@@ -88,15 +88,39 @@
> >
{{ ergebnis.platz }} {{ ergebnis.platz }}
</div> </div>
<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> <div>
<span class="font-semibold text-gray-900"> <span class="font-semibold text-gray-900">
{{ ergebnis.spieler1 }} {{ ergebnis.spieler1 }}
</span>
<span v-if="ergebnis.spieler2" class="text-gray-600"> <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 }} / {{ ergebnis.spieler2 }}
</span> </span>
</span> </span>
</div> </div>
</div> </div>
</div>
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
{{ ergebnis.platz === '1' ? 'Vereinsmeister' : ergebnis.platz + '. Platz' }} {{ ergebnis.platz === '1' ? 'Vereinsmeister' : ergebnis.platz + '. Platz' }}
</div> </div>
@@ -147,6 +171,37 @@
</div> </div>
</div> </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> </div>
</template> </template>
@@ -156,6 +211,7 @@ import { Trophy } from 'lucide-vue-next'
const results = ref([]) const results = ref([])
const selectedYear = ref('alle') const selectedYear = ref('alle')
const lightboxImage = ref(null)
const loadResults = async () => { const loadResults = async () => {
try { try {
@@ -188,6 +244,7 @@ const loadResults = async () => {
} }
values.push(current.trim()) values.push(current.trim())
// Mindestens 6 Spalten erforderlich (die neuen Bildspalten sind optional)
if (values.length < 6) return null if (values.length < 6) return null
return { return {
@@ -196,7 +253,9 @@ const loadResults = async () => {
platz: values[2].trim(), platz: values[2].trim(),
spieler1: values[3].trim(), spieler1: values[3].trim(),
spieler2: values[4].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) }).filter(result => result !== null)
} catch (error) { } catch (error) {
@@ -268,6 +327,26 @@ const totalDoubles = computed(() => {
return results.value.filter(r => r.kategorie === 'Doppel' && r.platz === '1').length 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(() => { onMounted(() => {
loadResults() loadResults()
}) })

View File

@@ -13,12 +13,12 @@
<div v-if="config" class="grid md:grid-cols-2 gap-8 not-prose"> <div v-if="config" class="grid md:grid-cols-2 gap-8 not-prose">
<!-- Vorsitzender --> <!-- Vorsitzender -->
<div v-if="config.vorstand.vorsitzender.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <PersonCard
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Vorsitzender</h3> v-if="config.vorstand.vorsitzender.vorname"
<h4 class="text-lg font-semibold text-primary-600 mb-3"> title="Vorsitzender"
{{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }} :name="`${config.vorstand.vorsitzender.vorname} ${config.vorstand.vorsitzender.nachname}`"
</h4> :image-filename="config.vorstand.vorsitzender.imageFilename"
<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.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.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.telefon">Tel. {{ config.vorstand.vorsitzender.telefon }}</p>
@@ -27,16 +27,15 @@
{{ config.vorstand.vorsitzender.email }} {{ config.vorstand.vorsitzender.email }}
</a> </a>
</p> </p>
</div> </PersonCard>
</div>
<!-- Stellvertreter --> <!-- Stellvertreter -->
<div v-if="config.vorstand.stellvertreter.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <PersonCard
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Stellvertreter</h3> v-if="config.vorstand.stellvertreter.vorname"
<h4 class="text-lg font-semibold text-primary-600 mb-3"> title="Stellvertreter"
{{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }} :name="`${config.vorstand.stellvertreter.vorname} ${config.vorstand.stellvertreter.nachname}`"
</h4> :image-filename="config.vorstand.stellvertreter.imageFilename"
<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.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.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.telefon">Tel. {{ config.vorstand.stellvertreter.telefon }}</p>
@@ -45,16 +44,15 @@
{{ config.vorstand.stellvertreter.email }} {{ config.vorstand.stellvertreter.email }}
</a> </a>
</p> </p>
</div> </PersonCard>
</div>
<!-- Kassenwart --> <!-- Kassenwart -->
<div v-if="config.vorstand.kassenwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <PersonCard
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Kassenwart</h3> v-if="config.vorstand.kassenwart.vorname"
<h4 class="text-lg font-semibold text-primary-600 mb-3"> title="Kassenwart"
{{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }} :name="`${config.vorstand.kassenwart.vorname} ${config.vorstand.kassenwart.nachname}`"
</h4> :image-filename="config.vorstand.kassenwart.imageFilename"
<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.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.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.telefon">Tel. {{ config.vorstand.kassenwart.telefon }}</p>
@@ -63,16 +61,15 @@
{{ config.vorstand.kassenwart.email }} {{ config.vorstand.kassenwart.email }}
</a> </a>
</p> </p>
</div> </PersonCard>
</div>
<!-- Schriftführer --> <!-- Schriftführer -->
<div v-if="config.vorstand.schriftfuehrer.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <PersonCard
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Schriftführer</h3> v-if="config.vorstand.schriftfuehrer.vorname"
<h4 class="text-lg font-semibold text-primary-600 mb-3"> title="Schriftführer"
{{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }} :name="`${config.vorstand.schriftfuehrer.vorname} ${config.vorstand.schriftfuehrer.nachname}`"
</h4> :image-filename="config.vorstand.schriftfuehrer.imageFilename"
<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.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.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.telefon">Tel. {{ config.vorstand.schriftfuehrer.telefon }}</p>
@@ -81,16 +78,15 @@
{{ config.vorstand.schriftfuehrer.email }} {{ config.vorstand.schriftfuehrer.email }}
</a> </a>
</p> </p>
</div> </PersonCard>
</div>
<!-- Sportwart --> <!-- Sportwart -->
<div v-if="config.vorstand.sportwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <PersonCard
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Sportwart</h3> v-if="config.vorstand.sportwart.vorname"
<h4 class="text-lg font-semibold text-primary-600 mb-3"> title="Sportwart"
{{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }} :name="`${config.vorstand.sportwart.vorname} ${config.vorstand.sportwart.nachname}`"
</h4> :image-filename="config.vorstand.sportwart.imageFilename"
<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.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.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.telefon">Tel. {{ config.vorstand.sportwart.telefon }}</p>
@@ -99,16 +95,15 @@
{{ config.vorstand.sportwart.email }} {{ config.vorstand.sportwart.email }}
</a> </a>
</p> </p>
</div> </PersonCard>
</div>
<!-- Jugendwart --> <!-- Jugendwart -->
<div v-if="config.vorstand.jugendwart.vorname" class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <PersonCard
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">Jugendwart</h3> v-if="config.vorstand.jugendwart.vorname"
<h4 class="text-lg font-semibold text-primary-600 mb-3"> title="Jugendwart"
{{ config.vorstand.jugendwart.vorname }} {{ config.vorstand.jugendwart.nachname }} :name="`${config.vorstand.jugendwart.vorname} ${config.vorstand.jugendwart.nachname}`"
</h4> :image-filename="config.vorstand.jugendwart.imageFilename"
<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.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.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.telefon">Tel. {{ config.vorstand.jugendwart.telefon }}</p>
@@ -117,8 +112,7 @@
{{ config.vorstand.jugendwart.email }} {{ config.vorstand.jugendwart.email }}
</a> </a>
</p> </p>
</div> </PersonCard>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,49 +1,49 @@
Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2
"2024","Einzel","1","Michael Koch","","" "2024","Einzel","1","Michael Koch","","","",""
"2024","Einzel","2","Olaf Nüßlein","","" "2024","Einzel","2","Olaf Nüßlein","","","",""
"2024","Einzel","3","Bernd Meyer","","" "2024","Einzel","3","Bernd Meyer","","","",""
"2024","Doppel","1","Sven Baublies","Johannes Binder","" "2024","Doppel","1","Sven Baublies","Johannes Binder","","",""
"2024","Doppel","2","Bernd Meyer","Jürgen Dichmann","" "2024","Doppel","2","Bernd Meyer","Jürgen Dichmann","","",""
"2024","Doppel","3","Michael Koch","Jacob Waltenberger","" "2024","Doppel","3","Michael Koch","Jacob Waltenberger","","",""
"2023","Einzel","1","André Gilzinger","","" "2023","Einzel","1","André Gilzinger","","","",""
"2023","Einzel","2","Olaf Nüßlein","","" "2023","Einzel","2","Olaf Nüßlein","","","",""
"2023","Einzel","3","Michael Koch","","" "2023","Einzel","3","Michael Koch","","","",""
"2023","Doppel","1","Olaf Nüßlein","Johannes Binder","" "2023","Doppel","1","Olaf Nüßlein","Johannes Binder","","",""
"2023","Doppel","2","Renate Nebel","André Gilzinger","" "2023","Doppel","2","Renate Nebel","André Gilzinger","","",""
"2023","Doppel","3","Ute Puschmann","Jürgen Kratz","" "2023","Doppel","3","Ute Puschmann","Jürgen Kratz","","",""
"2022","Einzel","1","Sven Baublies","","" "2022","Einzel","1","Sven Baublies","","","",""
"2022","Einzel","2","Thomas Steinbrech","","" "2022","Einzel","2","Thomas Steinbrech","","","",""
"2022","Einzel","3","André Gilzinger","","" "2022","Einzel","3","André Gilzinger","","","",""
"2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt","" "2022","Doppel","1","Sven Baublies","Kristin von Rauchhaupt","","",""
"2022","Doppel","2","Michael Weber","Johannes Binder","" "2022","Doppel","2","Michael Weber","Johannes Binder","","",""
"2022","Doppel","3","Michael Koch","Renate Nebel","" "2022","Doppel","3","Michael Koch","Renate Nebel","","",""
"2021","","","","","coronabedingter Ausfall" "2021","","","","","coronabedingter Ausfall","",""
"2020","","","","","coronabedingter Ausfall" "2020","","","","","coronabedingter Ausfall","",""
"2019","Einzel","1","André Gilzinger","","" "2019","Einzel","1","André Gilzinger","","","",""
"2019","Einzel","2","Thomas Steinbrech","","" "2019","Einzel","2","Thomas Steinbrech","","","",""
"2019","Einzel","3","Jürgen Kratz","","" "2019","Einzel","3","Jürgen Kratz","","","",""
"2019","Doppel","1","André Gilzinger","Volker Marx","" "2019","Doppel","1","André Gilzinger","Volker Marx","","",""
"2019","Doppel","2","Jürgen Kratz","Marko Wiedau","" "2019","Doppel","2","Jürgen Kratz","Marko Wiedau","","",""
"2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt","" "2019","Doppel","3","Bernd Meyer","Kristin von Rauchhaupt","","",""
"2018","Einzel","1","André Gilzinger","","" "2018","Einzel","1","André Gilzinger","","","",""
"2018","Einzel","2","Jürgen Kratz","","" "2018","Einzel","2","Jürgen Kratz","","","",""
"2018","Einzel","3","Sven Baublies","","" "2018","Einzel","3","Sven Baublies","","","",""
"2018","Doppel","1","André Gilzinger","Volker Marx","" "2018","Doppel","1","André Gilzinger","Volker Marx","","",""
"2018","Doppel","2","Sven Baublies","Helge Stefan","" "2018","Doppel","2","Sven Baublies","Helge Stefan","","",""
"2018","Doppel","3","Jürgen Kratz","Renate Nebel","" "2018","Doppel","3","Jürgen Kratz","Renate Nebel","","",""
"2017","Einzel","1","André Gilzinger","","" "2017","Einzel","1","André Gilzinger","","","",""
"2017","Einzel","2","Sven Baublies","","" "2017","Einzel","2","Sven Baublies","","","",""
"2017","Einzel","3","Olaf Nüßlein","","" "2017","Einzel","3","Olaf Nüßlein","","","",""
"2017","Doppel","1","Olaf Nüßlein","Helge Stefan","" "2017","Doppel","1","Olaf Nüßlein","Helge Stefan","","",""
"2017","Doppel","2","André Gilzinger","Renate Nebel","" "2017","Doppel","2","André Gilzinger","Renate Nebel","","",""
"2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt","" "2017","Doppel","3","Jürgen Kratz","Kristin von Rauchhaupt","","",""
"2016","Herren-Einzel","1","André Gilzinger","","" "2016","Herren-Einzel","1","André Gilzinger","","","",""
"2016","Herren-Einzel","2","Sven Baublies","","" "2016","Herren-Einzel","2","Sven Baublies","","","",""
"2016","Herren-Einzel","3","Olaf Nüßlein","","" "2016","Herren-Einzel","3","Olaf Nüßlein","","","",""
"2016","Damen-Einzel","1","Birgit Haas-Schrödter","","" "2016","Damen-Einzel","1","Birgit Haas-Schrödter","","","",""
"2016","Damen-Einzel","2","Kristin von Rauchhaupt","","" "2016","Damen-Einzel","2","Kristin von Rauchhaupt","","","",""
"2016","Damen-Einzel","3","Renate Nebel","","" "2016","Damen-Einzel","3","Renate Nebel","","","",""
"2016","Doppel","1","Jürgen Kratz","Matthias Schmidt","" "2016","Doppel","1","Jürgen Kratz","Matthias Schmidt","","",""
"2016","Doppel","2","André Gilzinger","Bernd Meyer","" "2016","Doppel","2","André Gilzinger","Bernd Meyer","","",""
"2016","Doppel","3","Sven Baublies","Dagmar Bereksasi","" "2016","Doppel","3","Sven Baublies","Dagmar Bereksasi","","",""
"2025","Doppel","1","a","b","" "2025","Doppel","1","a","b","","7f6c46f8-b93f-4807-b369-b26e0bba2da5.png","4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png"
1 Jahr Kategorie Platz Spieler1 Spieler2 Bemerkung imageFilename1 imageFilename2
2 2024 Einzel 1 Michael Koch
3 2024 Einzel 2 Olaf Nüßlein
4 2024 Einzel 3 Bernd Meyer
5 2024 Doppel 1 Sven Baublies Johannes Binder
6 2024 Doppel 2 Bernd Meyer Jürgen Dichmann
7 2024 Doppel 3 Michael Koch Jacob Waltenberger
8 2023 Einzel 1 André Gilzinger
9 2023 Einzel 2 Olaf Nüßlein
10 2023 Einzel 3 Michael Koch
11 2023 Doppel 1 Olaf Nüßlein Johannes Binder
12 2023 Doppel 2 Renate Nebel André Gilzinger
13 2023 Doppel 3 Ute Puschmann Jürgen Kratz
14 2022 Einzel 1 Sven Baublies
15 2022 Einzel 2 Thomas Steinbrech
16 2022 Einzel 3 André Gilzinger
17 2022 Doppel 1 Sven Baublies Kristin von Rauchhaupt
18 2022 Doppel 2 Michael Weber Johannes Binder
19 2022 Doppel 3 Michael Koch Renate Nebel
20 2021 coronabedingter Ausfall
21 2020 coronabedingter Ausfall
22 2019 Einzel 1 André Gilzinger
23 2019 Einzel 2 Thomas Steinbrech
24 2019 Einzel 3 Jürgen Kratz
25 2019 Doppel 1 André Gilzinger Volker Marx
26 2019 Doppel 2 Jürgen Kratz Marko Wiedau
27 2019 Doppel 3 Bernd Meyer Kristin von Rauchhaupt
28 2018 Einzel 1 André Gilzinger
29 2018 Einzel 2 Jürgen Kratz
30 2018 Einzel 3 Sven Baublies
31 2018 Doppel 1 André Gilzinger Volker Marx
32 2018 Doppel 2 Sven Baublies Helge Stefan
33 2018 Doppel 3 Jürgen Kratz Renate Nebel
34 2017 Einzel 1 André Gilzinger
35 2017 Einzel 2 Sven Baublies
36 2017 Einzel 3 Olaf Nüßlein
37 2017 Doppel 1 Olaf Nüßlein Helge Stefan
38 2017 Doppel 2 André Gilzinger Renate Nebel
39 2017 Doppel 3 Jürgen Kratz Kristin von Rauchhaupt
40 2016 Herren-Einzel 1 André Gilzinger
41 2016 Herren-Einzel 2 Sven Baublies
42 2016 Herren-Einzel 3 Olaf Nüßlein
43 2016 Damen-Einzel 1 Birgit Haas-Schrödter
44 2016 Damen-Einzel 2 Kristin von Rauchhaupt
45 2016 Damen-Einzel 3 Renate Nebel
46 2016 Doppel 1 Jürgen Kratz Matthias Schmidt
47 2016 Doppel 2 André Gilzinger Bernd Meyer
48 2016 Doppel 3 Sven Baublies Dagmar Bereksasi
49 2025 Doppel 1 a b 7f6c46f8-b93f-4807-b369-b26e0bba2da5.png 4f51e2e9-8cb0-4ce0-9395-ea5080361dd5.png

View 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'
})
}
})

View 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'
})
}
})

View File

@@ -36,7 +36,8 @@
"name": "Torsten Schulz", "name": "Torsten Schulz",
"lizenz": "C-Trainer", "lizenz": "C-Trainer",
"schwerpunkt": "Nachwuchsförderung", "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", "id": "2",
@@ -99,7 +100,8 @@
"plz": "60437", "plz": "60437",
"ort": "Frankfurt", "ort": "Frankfurt",
"telefon": "06101-9953015", "telefon": "06101-9953015",
"email": "rogerdichmann@gmx.de" "email": "rogerdichmann@gmx.de",
"imageFilename": "c24ef84d-2ae4-4edc-be01-063d9917da04.png"
}, },
"stellvertreter": { "stellvertreter": {
"vorname": "Jürgen", "vorname": "Jürgen",

View File

@@ -117,5 +117,12 @@
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYyMzMzNDIzLCJleHAiOjE3NjI5MzgyMjN9.V-L5ethO0VFSOPT2qbsQF2zQYQZSlese1rL5sIFaHbY", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImFkbWluQGhhcmhlaW1lcnRjLmRlIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzYyMzMzNDIzLCJleHAiOjE3NjI5MzgyMjN9.V-L5ethO0VFSOPT2qbsQF2zQYQZSlese1rL5sIFaHbY",
"createdAt": "2025-11-05T09:03:43.617Z", "createdAt": "2025-11-05T09:03:43.617Z",
"expiresAt": "2025-11-12T09: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"
} }
] ]

View File

@@ -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=