From 55a84b94a0fc9859f36ea5c30b6b689663b0c54e Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 18 Dec 2025 13:37:03 +0100 Subject: [PATCH] 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. --- backups/users-1766060412221/users.json | 1 + components/ImageUpload.vue | 143 +++++++++++++++++++ components/PersonCard.vue | 35 +++++ pages/cms/einstellungen.vue | 12 ++ pages/cms/vereinsmeisterschaften.vue | 116 ++++++++++----- pages/training/trainer.vue | 8 ++ pages/vereinsmeisterschaften.vue | 93 ++++++++++++- pages/vorstand.vue | 186 ++++++++++++------------- server/api/personen/[filename].get.js | 108 ++++++++++++++ server/api/personen/upload.post.js | 127 +++++++++++++++++ server/data/config.json | 6 +- server/data/sessions.json | 7 + server/data/users.json | 2 +- 13 files changed, 707 insertions(+), 137 deletions(-) create mode 100644 backups/users-1766060412221/users.json create mode 100644 components/ImageUpload.vue create mode 100644 components/PersonCard.vue create mode 100644 server/api/personen/[filename].get.js create mode 100644 server/api/personen/upload.post.js diff --git a/backups/users-1766060412221/users.json b/backups/users-1766060412221/users.json new file mode 100644 index 0000000..5f5658c --- /dev/null +++ b/backups/users-1766060412221/users.json @@ -0,0 +1 @@ +vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ== \ No newline at end of file diff --git a/components/ImageUpload.vue b/components/ImageUpload.vue new file mode 100644 index 0000000..a0d4a30 --- /dev/null +++ b/components/ImageUpload.vue @@ -0,0 +1,143 @@ + + + + diff --git a/components/PersonCard.vue b/components/PersonCard.vue new file mode 100644 index 0000000..9b7567f --- /dev/null +++ b/components/PersonCard.vue @@ -0,0 +1,35 @@ + + + + diff --git a/pages/cms/einstellungen.vue b/pages/cms/einstellungen.vue index d978f4e..da3dacd 100644 --- a/pages/cms/einstellungen.vue +++ b/pages/cms/einstellungen.vue @@ -295,6 +295,12 @@ +
+ +
@@ -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" /> +
+ +
diff --git a/pages/cms/vereinsmeisterschaften.vue b/pages/cms/vereinsmeisterschaften.vue index faa408e..943c340 100644 --- a/pages/cms/vereinsmeisterschaften.vue +++ b/pages/cms/vereinsmeisterschaften.vue @@ -123,9 +123,28 @@ {{ result.platz }} -
+
+
+ +
{{ result.spieler1 }} - & {{ result.spieler2 }} + + & +
+ +
+ {{ result.spieler2 }} +
@@ -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" > -
-

- {{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }} -

+
+
+

+ {{ editingResult ? 'Ergebnis bearbeiten' : 'Neues Ergebnis hinzufügen' }} +

+
-
+
+
+
+ +
+ +
+ +
+
-
- - -
- + +
+ +
+ + +
@@ -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(',') }) diff --git a/pages/training/trainer.vue b/pages/training/trainer.vue index 17ead0b..ed78b27 100644 --- a/pages/training/trainer.vue +++ b/pages/training/trainer.vue @@ -16,6 +16,14 @@ :key="trainer.id" class="bg-white p-8 rounded-xl shadow-lg" > +
+ +

{{ trainer.lizenz }}

{{ trainer.name }}

diff --git a/pages/vereinsmeisterschaften.vue b/pages/vereinsmeisterschaften.vue index aa7a636..f7b60a5 100644 --- a/pages/vereinsmeisterschaften.vue +++ b/pages/vereinsmeisterschaften.vue @@ -88,13 +88,37 @@ > {{ ergebnis.platz }}

-
- - {{ ergebnis.spieler1 }} - - / {{ ergebnis.spieler2 }} +
+
+ +
+
+ + {{ ergebnis.spieler1 }} - + + + / + + {{ ergebnis.spieler2 }} + + + / {{ ergebnis.spieler2 }} + + +
@@ -147,6 +171,37 @@
+ + +
+
+ + + + +
+

{{ lightboxImage.name }}

+
+
+
@@ -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() }) diff --git a/pages/vorstand.vue b/pages/vorstand.vue index 4ff3cf1..06f58d3 100644 --- a/pages/vorstand.vue +++ b/pages/vorstand.vue @@ -13,112 +13,106 @@
-
-

Vorsitzender

-

- {{ config.vorstand.vorsitzender.vorname }} {{ config.vorstand.vorsitzender.nachname }} -

-
-

{{ config.vorstand.vorsitzender.strasse }}

-

{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}

-

Tel. {{ config.vorstand.vorsitzender.telefon }}

-

- - {{ config.vorstand.vorsitzender.email }} - -

-
-
+ +

{{ config.vorstand.vorsitzender.strasse }}

+

{{ config.vorstand.vorsitzender.plz }} {{ config.vorstand.vorsitzender.ort }}

+

Tel. {{ config.vorstand.vorsitzender.telefon }}

+

+ + {{ config.vorstand.vorsitzender.email }} + +

+
-
-

Stellvertreter

-

- {{ config.vorstand.stellvertreter.vorname }} {{ config.vorstand.stellvertreter.nachname }} -

-
-

{{ config.vorstand.stellvertreter.strasse }}

-

{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}

-

Tel. {{ config.vorstand.stellvertreter.telefon }}

-

- - {{ config.vorstand.stellvertreter.email }} - -

-
-
+ +

{{ config.vorstand.stellvertreter.strasse }}

+

{{ config.vorstand.stellvertreter.plz }} {{ config.vorstand.stellvertreter.ort }}

+

Tel. {{ config.vorstand.stellvertreter.telefon }}

+

+ + {{ config.vorstand.stellvertreter.email }} + +

+
-
-

Kassenwart

-

- {{ config.vorstand.kassenwart.vorname }} {{ config.vorstand.kassenwart.nachname }} -

-
-

{{ config.vorstand.kassenwart.strasse }}

-

{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}

-

Tel. {{ config.vorstand.kassenwart.telefon }}

-

- - {{ config.vorstand.kassenwart.email }} - -

-
-
+ +

{{ config.vorstand.kassenwart.strasse }}

+

{{ config.vorstand.kassenwart.plz }} {{ config.vorstand.kassenwart.ort }}

+

Tel. {{ config.vorstand.kassenwart.telefon }}

+

+ + {{ config.vorstand.kassenwart.email }} + +

+
-
-

Schriftführer

-

- {{ config.vorstand.schriftfuehrer.vorname }} {{ config.vorstand.schriftfuehrer.nachname }} -

-
-

{{ config.vorstand.schriftfuehrer.strasse }}

-

{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}

-

Tel. {{ config.vorstand.schriftfuehrer.telefon }}

-

- - {{ config.vorstand.schriftfuehrer.email }} - -

-
-
+ +

{{ config.vorstand.schriftfuehrer.strasse }}

+

{{ config.vorstand.schriftfuehrer.plz }} {{ config.vorstand.schriftfuehrer.ort }}

+

Tel. {{ config.vorstand.schriftfuehrer.telefon }}

+

+ + {{ config.vorstand.schriftfuehrer.email }} + +

+
-
-

Sportwart

-

- {{ config.vorstand.sportwart.vorname }} {{ config.vorstand.sportwart.nachname }} -

-
-

{{ config.vorstand.sportwart.strasse }}

-

{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}

-

Tel. {{ config.vorstand.sportwart.telefon }}

-

- - {{ config.vorstand.sportwart.email }} - -

-
-
+ +

{{ config.vorstand.sportwart.strasse }}

+

{{ config.vorstand.sportwart.plz }} {{ config.vorstand.sportwart.ort }}

+

Tel. {{ config.vorstand.sportwart.telefon }}

+

+ + {{ config.vorstand.sportwart.email }} + +

+
-
-

Jugendwart

-

- {{ config.vorstand.jugendwart.vorname }} {{ config.vorstand.jugendwart.nachname }} -

-
-

{{ config.vorstand.jugendwart.strasse }}

-

{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}

-

Tel. {{ config.vorstand.jugendwart.telefon }}

-

- - {{ config.vorstand.jugendwart.email }} - -

-
-
+ +

{{ config.vorstand.jugendwart.strasse }}

+

{{ config.vorstand.jugendwart.plz }} {{ config.vorstand.jugendwart.ort }}

+

Tel. {{ config.vorstand.jugendwart.telefon }}

+

+ + {{ config.vorstand.jugendwart.email }} + +

+
diff --git a/server/api/personen/[filename].get.js b/server/api/personen/[filename].get.js new file mode 100644 index 0000000..526b635 --- /dev/null +++ b/server/api/personen/[filename].get.js @@ -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' + }) + } +}) + diff --git a/server/api/personen/upload.post.js b/server/api/personen/upload.post.js new file mode 100644 index 0000000..e235bd2 --- /dev/null +++ b/server/api/personen/upload.post.js @@ -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' + }) + } +}) + diff --git a/server/data/config.json b/server/data/config.json index bdc914e..5bee3e7 100644 --- a/server/data/config.json +++ b/server/data/config.json @@ -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", diff --git a/server/data/sessions.json b/server/data/sessions.json index 4a1df32..d2bcc0f 100644 --- a/server/data/sessions.json +++ b/server/data/sessions.json @@ -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" } ] \ No newline at end of file diff --git a/server/data/users.json b/server/data/users.json index 5f5658c..bec3142 100644 --- a/server/data/users.json +++ b/server/data/users.json @@ -1 +1 @@ -vt5myp1IVj2hMck3wi+hrAym+ZAIGNkg5zeSZcHwpt8NV9ZIj3KD1bPEbzTT7LhmlgspNL/HmTYwdUYN/yoxOxZ5d3usU+/q690XcuP4j4PzMtRc+xXVlA2oZT2lszkZtw0sm9auHI7NCAIViCqfpmnAtjsJPy9Pguni/9BH5hMJtNzR1zg0wIgigqA0eYLatRyMusk+hq0Bv2qodwOH0V6kQ9NHAj6lR6Dehs/nO8R+qjgtvWgYjxPR8RMtn62s8zFki3YcXi8Zweb/I0XUTS9VV4EukyZXpEGDs7ECiN6nesYNAHSB/PhC8rqrPjUPPna2s2sZjVgfY8WueuODw5oArRGfgzDhCz/eqpTS5pjMSrGJ8AygrC7R+l5KSSsMN2hHn/AwY6PAhUtbLe3mmQ== \ No newline at end of file +3+uWOe4pSXnAFtrdeCqRG+HvbRIsI2HUcMkzrEBlqEmf/9rasPUIv5xhfS+3vh3BJh89fjff0N9l7C8SZbe/ABq75ffwHa1rT72fExEAQ0B/TntBBARNeACYRtx7j3OTJs0+DPiJvraXshqqVjJQjFVMRk1PdmNs3wbZQ9JkXazyne+Gvb6NJWBAeBv4s5pOe3y06GnUO2ZMsGPX3nKdumbRjFXoNzOWtzMQy9m8GYTAQGtC+dMzRTAjKuPxMLLYT1e8hMhJkhbGDOB0+VgOG2o1zGa+eD2ayoHqzUBf5/RZs09rRspXJZ7HKjvgdnkJuO2lstjQeOFtzoljGE9EC2ueRGUuOsyi0AQrbBhVTj3wWIb5V+mNxNccKv9KDs4/EPwyu8l32Ql6kepNuXofZMbJHuwwYvXIvpIj31HdJP0= \ No newline at end of file