Compare commits

..

1 Commits

135 changed files with 30797 additions and 11807 deletions

7
.env Normal file
View File

@@ -0,0 +1,7 @@
SMTP_HOST=smtp.1blu.de
SMTP_PORT=465
SMTP_USER=e226079_0-kontakt
SMTP_PASS=hitomisan
SMTP_FROM=kontakt@tsschulz.de
FRONTEND_URL=http://localhost:8080
VUE_APP_BACKEND_URL=http://localhost:3002/api

View File

@@ -1,19 +0,0 @@
# E-Mail-Konfiguration für Passwort-Reset
SMTP_HOST=smtp.1blu.de
SMTP_PORT=465
SMTP_USER=your-email@domain.com
SMTP_PASS=your-password
SMTP_FROM=noreply@miriamgemeinde.de
# Frontend-URL für Reset-Links
FRONTEND_URL=http://localhost:8080
# Backend-URL für das Frontend
VUE_APP_BACKEND_URL=http://localhost:3002/api
# Datenbank-Konfiguration (falls benötigt)
DB_HOST=localhost
DB_PORT=3306
DB_NAME=miriamgemeinde
DB_USER=miriam_user
DB_PASS=your-database-password

View File

@@ -1,4 +0,0 @@
dist/
public/
node_modules/

View File

@@ -1,55 +0,0 @@
name: Deploy miriamgemeinde
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
SSH_HOST: ${{ vars.PROD_HOST }}
SSH_PORT: ${{ vars.PROD_PORT }}
SSH_USER: ${{ vars.PROD_USER }}
steps:
- name: Show resolved non-secret config
run: |
echo "SSH_HOST=$SSH_HOST"
echo "SSH_PORT=$SSH_PORT"
echo "SSH_USER=$SSH_USER"
- name: Prepare SSH
run: |
set -e
mkdir -p ~/.ssh
printf '%s' "${{ secrets.PROD_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keygen -l -f ~/.ssh/id_deploy
ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
set -e
ssh -i ~/.ssh/id_deploy \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-o ConnectTimeout=10 \
-p "$SSH_PORT" \
"$SSH_USER@$SSH_HOST" \
"echo SSH OK"
# If you need server-side preparation (e.g. ensure /var/... exists/permissions),
# add it in the remote command before running the update script.
- name: Run deployment script
run: |
set -e
ssh -i ~/.ssh/id_deploy \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-o ConnectTimeout=10 \
-p "$SSH_PORT" \
"$SSH_USER@$SSH_HOST" \
"bash -lc 'set -euo pipefail; TS=\$(date +\"%Y-%m-%d_%H%M%S\"); SRC=\"/var/www/miriamgemeinde/public/images\"; DEST_BASE=\"/home/torsten/miriamgemeinde/backup/\$TS\"; mkdir -p \"\$DEST_BASE\"; if [ -d \"\$SRC\" ]; then cp -a \"\$SRC\" \"\$DEST_BASE/\"; echo \"Backed up \$SRC -> \$DEST_BASE/images\"; else echo \"WARN: \$SRC does not exist; skipping backup\"; fi; if [ -f /home/torsten/update-miriamgemeinde.sh ]; then bash /home/torsten/update-miriamgemeinde.sh; else echo \"ERROR: /home/torsten/update-miriamgemeinde.sh not found\"; exit 127; fi'"

13
.gitignore vendored
View File

@@ -27,16 +27,3 @@ server.key
server.cert server.cert
public/images/uploads/1ba24ea7-f52c-4179-896f-1909269cab58.jpg public/images/uploads/1ba24ea7-f52c-4179-896f-1909269cab58.jpg
# Vue Build-Artefakte (werden beim Deploy generiert)
/public/index.html
/public/assets/
public/js/
public/css/
public/**/*.map
# Uploads/Runtime-Dateien nicht versionieren
public/images/uploads/
actualize.sh
files/uploads/GD 24.08.2025-04.01.2026 Stand 12.08.2025.docx
.codex

5
babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@@ -1,21 +1,21 @@
{ {
"development": { "development": {
"username": "miriam_user", "username": "miriam_user",
"password": "hitomisan", "password": "qTCTTWwpEwy3vPDU",
"database": "miriamgemeinde", "database": "miriamgemeinde",
"host": "tsschulz.de", "host": "tsschulz.de",
"dialect": "mysql" "dialect": "mysql"
}, },
"test": { "test": {
"username": "miriam_user", "username": "miriam_user",
"password": "hitomisan", "password": "qTCTTWwpEwy3vPDU",
"database": "miriamgemeinde", "database": "miriamgemeinde",
"host": "tsschulz.de", "host": "tsschulz.de",
"dialect": "mysql" "dialect": "mysql"
}, },
"production": { "production": {
"username": "miriam_user", "username": "miriam_user",
"password": "hitomisan", "password": "qTCTTWwpEwy3vPDU",
"database": "miriamgemeinde", "database": "miriamgemeinde",
"host": "tsschulz.de", "host": "tsschulz.de",
"dialect": "mysql" "dialect": "mysql"

View File

@@ -1,25 +1,8 @@
const { Sequelize } = require('sequelize'); const { Sequelize } = require('sequelize');
require('dotenv').config();
const envName = process.env.NODE_ENV || 'development'; const sequelize = new Sequelize('miriamgemeinde', 'miriam_user', 'qTCTTWwpEwy3vPDU', {
const fileConfig = require('./config.json')[envName]; host: 'tsschulz.de',
dialect: 'mysql',
if (!fileConfig) {
throw new Error(
`[DB] Kein Eintrag in config/config.json für NODE_ENV="${envName}".`
);
}
const database = process.env.DB_NAME || fileConfig.database;
const username = process.env.DB_USER || fileConfig.username;
const password =
process.env.DB_PASSWORD === undefined ? fileConfig.password : process.env.DB_PASSWORD;
const host = process.env.DB_HOST || fileConfig.host;
const sequelizeOptions = {
host,
dialect: fileConfig.dialect || 'mysql',
dialectOptions: fileConfig.dialectOptions,
retry: { retry: {
match: [ match: [
/ConnectionError/, /ConnectionError/,
@@ -28,38 +11,24 @@ const sequelizeOptions = {
/SequelizeHostNotFoundError/, /SequelizeHostNotFoundError/,
/SequelizeHostNotReachableError/, /SequelizeHostNotReachableError/,
/SequelizeInvalidConnectionError/, /SequelizeInvalidConnectionError/,
/SequelizeConnectionTimedOutError/, /SequelizeConnectionTimedOutError/
], ],
max: 5, max: 5
}, },
pool: { pool: {
max: 5, max: 5,
min: 0, min: 0,
acquire: 30000, acquire: 30000,
idle: 10000, idle: 10000
}, }
logging: process.env.DB_LOGGING === '1' ? console.log : false, });
};
if (process.env.DB_PORT) {
sequelizeOptions.port = parseInt(process.env.DB_PORT, 10);
} else if (fileConfig.port) {
sequelizeOptions.port = fileConfig.port;
}
const sequelize = new Sequelize(database, username, password, sequelizeOptions);
async function connectWithRetry() { async function connectWithRetry() {
try { try {
await sequelize.authenticate(); await sequelize.authenticate();
console.log( console.log('Connection has been established successfully.');
`[DB] Verbindung OK — host=${host} database=${database} user=${username} (NODE_ENV=${envName})`
);
} catch (error) { } catch (error) {
console.error('[DB] Verbindung fehlgeschlagen:', error.message); console.error('Unable to connect to the database:', error);
console.error(
`[DB] Erwartete Quelle: config/config.json → "${envName}" oder Umgebungsvariablen DB_HOST, DB_USER, DB_PASSWORD, DB_NAME`
);
setTimeout(connectWithRetry, 5000); setTimeout(connectWithRetry, 5000);
} }
} }

View File

@@ -1,209 +1,29 @@
const bcrypt = require('bcryptjs'); const AuthService = require('../services/AuthService');
const { User, PasswordResetToken } = require('../models'); const ErrorHandler = require('../utils/ErrorHandler');
const jwt = require('jsonwebtoken');
const { addTokenToBlacklist } = require('../utils/blacklist');
const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
const crypto = require('crypto');
function delay(ms) { exports.register = ErrorHandler.asyncHandler(async (req, res) => {
return new Promise(resolve => setTimeout(resolve, ms)); const result = await AuthService.register(req.body);
} ErrorHandler.successResponse(res, result, 'Benutzer erfolgreich registriert', 201);
});
exports.register = async (req, res) => { exports.login = ErrorHandler.asyncHandler(async (req, res) => {
const { name, email, password } = req.body; const result = await AuthService.login(req.body);
if (!name || !email || !password) { ErrorHandler.successResponse(res, result, result.message);
return res.status(400).json({ message: 'Alle Felder sind erforderlich' }); });
}
try {
const hashedPassword = await bcrypt.hash(password, 10);
console.log('Register: creating user', { email });
const maxAttempts = 3; exports.forgotPassword = ErrorHandler.asyncHandler(async (req, res) => {
let attempt = 0; const result = await AuthService.forgotPassword(req.body.email);
let createdUser = null; ErrorHandler.successResponse(res, result, result.message);
let lastError = null; });
while (attempt < maxAttempts && !createdUser) { exports.resetPassword = ErrorHandler.asyncHandler(async (req, res) => {
try { const result = await AuthService.resetPassword(req.body.token, req.body.password);
createdUser = await User.create({ name, email, password: hashedPassword, active: false }); ErrorHandler.successResponse(res, result, result.message);
} catch (err) { });
lastError = err;
// Spezifisch auf Lock-Timeout reagieren und erneut versuchen
if ((err.code === 'ER_LOCK_WAIT_TIMEOUT' || err?.parent?.code === 'ER_LOCK_WAIT_TIMEOUT') && attempt < maxAttempts - 1) {
const backoffMs = 300 * (attempt + 1);
console.warn(`Register: ER_LOCK_WAIT_TIMEOUT, retry in ${backoffMs}ms (attempt ${attempt + 1}/${maxAttempts})`);
await delay(backoffMs);
attempt++;
continue;
}
throw err;
}
}
if (!createdUser && lastError) { exports.logout = ErrorHandler.asyncHandler(async (req, res) => {
console.error('Register error (after retries):', lastError);
return res.status(503).json({ message: 'Zeitüberschreitung beim Zugriff auf die Datenbank. Bitte erneut versuchen.' });
}
console.log('Register: user created', { id: createdUser.id });
const safeUser = {
id: createdUser.id,
name: createdUser.name,
email: createdUser.email,
active: createdUser.active,
created_at: createdUser.created_at
};
return res.status(201).json({ message: 'Benutzer erfolgreich registriert', user: safeUser });
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ message: 'Email-Adresse bereits in Verwendung' });
}
console.error('Register error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten', error: error.message });
}
};
exports.login = async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email und Passwort sind erforderlich' });
}
try {
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ message: 'Ungültige Anmeldedaten' });
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ message: 'Ungültige Anmeldedaten' });
}
if (!user.active) {
return res.status(403).json({ message: 'Benutzerkonto ist nicht aktiv' });
}
const token = jwt.sign(
{ id: user.id, name: user.name, email: user.email },
'zTxVgptmPl9!_dr%xxx9999(dd)',
{ expiresIn: process.env.JWT_EXPIRES_IN || '12h' }
);
return res.status(200).json({ message: 'Login erfolgreich', token, 'user': user });
} catch (error) {
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.forgotPassword = async (req, res) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ message: 'E-Mail-Adresse ist erforderlich' });
}
try {
const user = await User.findOne({ where: { email } });
if (!user) {
// Aus Sicherheitsgründen immer Erfolg melden, auch wenn E-Mail nicht existiert
return res.status(200).json({ message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' });
}
// Alte Reset-Tokens für diesen User löschen
await PasswordResetToken.destroy({ where: { userId: user.id } });
// Neuen Reset-Token generieren
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde
await PasswordResetToken.create({
userId: user.id,
token,
expiresAt
});
// Reset-URL generieren
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`;
// E-Mail versenden
const emailTemplate = getPasswordResetEmailTemplate(resetUrl, user.name);
const mailOptions = {
from: process.env.SMTP_FROM || 'noreply@miriamgemeinde.de',
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text
};
console.log('=== EMAIL SENDING DEBUG ===');
console.log('From:', mailOptions.from);
console.log('To:', mailOptions.to);
console.log('Subject:', mailOptions.subject);
console.log('Reset URL:', resetUrl);
console.log('===========================');
await transporter.sendMail(mailOptions);
console.log('Password reset email sent to:', email);
return res.status(200).json({ message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' });
} catch (error) {
console.error('Forgot password error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.resetPassword = async (req, res) => {
const { token, password } = req.body;
if (!token || !password) {
return res.status(400).json({ message: 'Token und neues Passwort sind erforderlich' });
}
if (password.length < 6) {
return res.status(400).json({ message: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
try {
// Token validieren
const resetToken = await PasswordResetToken.findOne({
where: {
token,
used: false,
expiresAt: {
[require('sequelize').Op.gt]: new Date()
}
},
include: [{ model: User, as: 'user' }]
});
if (!resetToken) {
return res.status(400).json({ message: 'Ungültiger oder abgelaufener Token' });
}
// Passwort hashen und aktualisieren
const hashedPassword = await bcrypt.hash(password, 10);
await User.update(
{ password: hashedPassword },
{ where: { id: resetToken.userId } }
);
// Token als verwendet markieren
await resetToken.update({ used: true });
console.log('Password reset successful for user:', resetToken.userId);
return res.status(200).json({ message: 'Passwort erfolgreich zurückgesetzt' });
} catch (error) {
console.error('Reset password error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.logout = async (req, res) => {
const authHeader = req.header('Authorization'); const authHeader = req.header('Authorization');
if (!authHeader) { const token = authHeader ? authHeader.replace('Bearer ', '') : null;
return res.status(400).json({ message: 'Kein Token bereitgestellt' }); const result = await AuthService.logout(token);
} ErrorHandler.successResponse(res, result, result.message);
const token = authHeader.replace('Bearer ', ''); });
try {
addTokenToBlacklist(token);
return res.status(200).json({ message: 'Logout erfolgreich' });
} catch (error) {
console.log(error);
return res.status(500).json({ message: 'Ein Fehler ist beim Logout aufgetreten' });
}
};

View File

@@ -3,16 +3,7 @@ const { Op } = require('sequelize');
const getAllContactPersons = async (req, res) => { const getAllContactPersons = async (req, res) => {
try { try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const contactPersons = await ContactPerson.findAll({ const contactPersons = await ContactPerson.findAll({
where: {
[Op.or]: [
{ expiryDate: null },
{ expiryDate: { [Op.gte]: today } }
]
},
include: [ include: [
{ {
model: Position, model: Position,
@@ -88,14 +79,6 @@ const filterContactPersons = async (req, res) => {
const where = {}; const where = {};
const having = []; const having = [];
// Filter für nicht abgelaufene Kontaktpersonen
const today = new Date();
today.setHours(0, 0, 0, 0);
where[Op.or] = [
{ expiryDate: null },
{ expiryDate: { [Op.gte]: today } }
];
if (config.selection.id && config.selection.id === 'all') { if (config.selection.id && config.selection.id === 'all') {
// No additional filter needed for "all" // No additional filter needed for "all"
} else if (config.selection.id) { } else if (config.selection.id) {

View File

@@ -1,208 +1,32 @@
const { Event, Institution, EventPlace, ContactPerson, EventType, EventContactPerson, sequelize } = require('../models'); const EventService = require('../services/EventService');
const { Op, fn, col, where: sequelizeWhere } = require('sequelize'); const ErrorHandler = require('../utils/ErrorHandler');
function buildUpcomingWhere() { exports.getAllEvents = ErrorHandler.asyncHandler(async (req, res) => {
return { const events = await EventService.getAllEvents();
[Op.or]: [ ErrorHandler.successResponse(res, events, 'Events erfolgreich abgerufen');
{ date: { [Op.eq]: null } }, });
// Fixed-date events: only today or future (date-only compare).
sequelizeWhere(fn('DATE', col('date')), { [Op.gte]: fn('CURDATE') }),
],
};
}
const getAllEvents = async (req, res) => { exports.getEventById = ErrorHandler.asyncHandler(async (req, res) => {
try { const event = await EventService.getEventById(req.params.id);
const includePast = String(req.query?.includePast || '').toLowerCase(); ErrorHandler.successResponse(res, event, 'Event erfolgreich abgerufen');
const wantsPast = includePast === '1' || includePast === 'true' || includePast === 'yes'; });
const where = wantsPast exports.filterEvents = ErrorHandler.asyncHandler(async (req, res) => {
? undefined const result = await EventService.filterEvents(req.body);
: buildUpcomingWhere(); ErrorHandler.successResponse(res, result, 'Events erfolgreich gefiltert');
});
const events = await Event.findAll({ exports.createEvent = ErrorHandler.asyncHandler(async (req, res) => {
where, const event = await EventService.createEvent(req.body);
include: [ ErrorHandler.successResponse(res, event, 'Event erfolgreich erstellt', 201);
{ model: Institution, as: 'institution' }, });
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: [
['date', 'ASC'],
['time', 'ASC'],
['name', 'ASC'],
],
});
res.json(events);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch events' });
console.error(error);
}
};
const filterEvents = async (req, res) => { exports.updateEvent = ErrorHandler.asyncHandler(async (req, res) => {
try { const event = await EventService.updateEvent(req.params.id, req.body);
const request = req.body; ErrorHandler.successResponse(res, event, 'Event erfolgreich aktualisiert');
const where = buildUpcomingWhere(); });
const order = [
['date', 'ASC'],
['time', 'ASC']
];
if (request.id === 'all') { exports.deleteEvent = ErrorHandler.asyncHandler(async (req, res) => {
const events = await Event.findAll({ const result = await EventService.deleteEvent(req.params.id);
where, ErrorHandler.successResponse(res, result, result.message);
include: [ });
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
logging: console.log // Log the generated SQL query
});
return res.json({ events });
}
if (request.id === 'home') {
const events = await Event.findAll({
where: {
alsoOnHomepage: 1,
...buildUpcomingWhere(),
},
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } },
],
order: order,
});
return res.json({ events });
}
if (!request.id && !request.places && !request.types) {
return res.json({ events: [], places: [], types: [], contactPersons: [] });
}
if (request.id) {
where.id = request.id;
}
if (request.places && request.places.length > 0) {
where.event_place_id = {
[Op.in]: request.places.map(id => parseInt(id))
};
}
if (request.types && request.types.length > 0) {
where.eventTypeId = {
[Op.in]: request.types.map(id => parseInt(id))
};
}
const events = await Event.findAll({
where,
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
});
const displayFields = request.display ? request.display : [];
const filteredEvents = events.map(event => {
const filteredEvent = { ...event.toJSON() };
if (!displayFields.includes('name')) delete filteredEvent.name;
if (!displayFields.includes('type')) delete filteredEvent.eventType;
if (!displayFields.includes('place')) delete filteredEvent.eventPlace;
if (!displayFields.includes('description')) delete filteredEvent.description;
if (!displayFields.includes('time')) delete filteredEvent.time;
if (!displayFields.includes('time')) delete filteredEvent.endTime;
if (!displayFields.includes('contactPerson')) delete filteredEvent.contactPersons;
if (!displayFields.includes('day')) delete filteredEvent.dayOfWeek;
if (!displayFields.includes('institution')) delete filteredEvent.institution;
return filteredEvent;
});
res.json({ events: filteredEvents });
} catch (error) {
res.status(500).json({ error: 'Failed to filter events' });
console.error(error);
}
};
const createEvent = async (req, res) => {
try {
const { contactPersonIds, ...eventData } = req.body;
eventData.alsoOnHomepage = eventData.alsoOnHomepage ?? 0;
const event = await Event.create(eventData);
if (contactPersonIds) {
await event.setContactPersons(contactPersonIds);
}
res.status(201).json(event);
} catch (error) {
res.status(500).json({ error: 'Failed to create event' });
console.error(error);
}
};
const updateEvent = async (req, res) => {
try {
const { id } = req.params;
const { contactPersonIds, ...eventData } = req.body;
const event = await Event.findByPk(id);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
await event.update(eventData);
if (contactPersonIds) {
await event.setContactPersons(contactPersonIds);
}
res.status(200).json(event);
} catch (error) {
res.status(500).json({ error: 'Failed to update event' });
console.error(error);
}
};
const deleteEvent = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { id } = req.params;
// Erst Zuordnungen in der Join-Tabelle löschen, damit FK-Constraints erfüllt sind.
await EventContactPerson.destroy({
where: { event_id: id },
transaction,
});
const deleted = await Event.destroy({
where: { id: id },
transaction,
});
if (deleted) {
await transaction.commit();
res.status(204).json();
} else {
await transaction.rollback();
res.status(404).json({ error: 'Event not found' });
}
} catch (error) {
await transaction.rollback();
res.status(500).json({ error: 'Failed to delete event' });
console.error(error);
}
};
module.exports = {
getAllEvents,
createEvent,
updateEvent,
deleteEvent,
filterEvents
};

View File

@@ -16,7 +16,6 @@ const storage = multer.diskStorage({
const upload = multer({ storage }); const upload = multer({ storage });
exports.uploadImage = upload.single('image'); exports.uploadImage = upload.single('image');
exports.uploadImages = upload.array('images');
exports.getAllPages = async (req, res) => { exports.getAllPages = async (req, res) => {
try { try {
@@ -52,51 +51,6 @@ exports.saveImageDetails = async (req, res) => {
} }
}; };
exports.saveImageDetailsBulk = async (req, res) => {
try {
const { baseTitle, description, page, startNumber } = req.body;
const files = req.files || [];
if (!baseTitle || !String(baseTitle).trim()) {
return res.status(400).json({ error: 'Bitte einen Basis-Titel angeben.' });
}
if (files.length === 0) {
return res.status(400).json({ error: 'Bitte mindestens ein Bild auswählen.' });
}
const pageItem = page ? await Page.findAll({ where: { link: page } }) : [];
const pageId = pageItem && pageItem[0] ? pageItem[0].id : null;
const firstNumber = Number.parseInt(startNumber, 10);
const startAt = Number.isFinite(firstNumber) ? firstNumber : 1;
const createdImages = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const runningNumber = startAt + i;
const title = `${String(baseTitle).trim()} ${runningNumber}`;
const newImage = await Image.create({
id: uuidv4(),
filename: file.filename,
title,
description: description || '',
pageId,
});
createdImages.push(newImage);
}
// Menübild nur dann setzen, wenn genau ein Bild hochgeladen wurde.
if (page && createdImages.length === 1) {
const imageUrl = `/uploads/${createdImages[0].filename}`;
await MenuItem.update({ image: imageUrl }, { where: { link: page } });
}
res.status(201).json(createdImages);
} catch (error) {
console.error('Fehler beim Bulk-Upload der Bilder:', error);
res.status(500).json({ error: 'Fehler beim Bulk-Upload der Bilder' });
}
};
exports.getImages = async (req, res) => { exports.getImages = async (req, res) => {
try { try {
const images = await Image.findAll({ order: [['title', 'ASC']] }); const images = await Image.findAll({ order: [['title', 'ASC']] });

View File

@@ -1,154 +0,0 @@
const { LiturgicalDay } = require('../models');
const { Op } = require('sequelize');
const axios = require('axios');
// Alle liturgischen Tage abrufen
const getAllLiturgicalDays = async (req, res) => {
try {
const days = await LiturgicalDay.findAll({
order: [['date', 'ASC']]
});
res.status(200).json(days);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Abrufen der liturgischen Tage' });
}
};
// Eindeutige Namen für Multiselect abrufen
const getLiturgicalDayNames = async (req, res) => {
try {
const days = await LiturgicalDay.findAll({
attributes: ['dayName'],
group: ['dayName'],
order: [['dayName', 'ASC']]
});
const names = days.map(day => day.dayName);
res.status(200).json(names);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Abrufen der Tag-Namen' });
}
};
// HTML von liturgischem Kalender parsen und in DB speichern
const loadLiturgicalYear = async (req, res) => {
const { year } = req.body;
if (!year) {
return res.status(400).json({ message: 'Jahr ist erforderlich' });
}
const currentYear = new Date().getFullYear();
if (year < currentYear || year > currentYear + 2) {
return res.status(400).json({ message: 'Jahr muss zwischen aktuellem Jahr und 2 Jahren in der Zukunft liegen' });
}
try {
const url = `https://www.eike-fleer.de/liturgischer-kalender/${year}.htm`;
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
const html = response.data;
// Parse HTML - suche nach Tabellenzeilen mit Datum und Name
// Format: "DD.MM.YYYY &nbsp; &nbsp; &nbsp; DayName"
const regex = /(\d{2}\.\d{2}\.\d{4})\s*(?:&nbsp;|\s)+(.+?)(?:<\/|$)/gi;
const matches = [...html.matchAll(regex)];
const liturgicalDays = [];
for (const match of matches) {
const dateStr = match[1]; // DD.MM.YYYY
let dayName = match[2];
// Bereinige den Tag-Namen von HTML-Tags und Entities
dayName = dayName
.replace(/<[^>]*>/g, '') // Entferne HTML-Tags
.replace(/&nbsp;/g, ' ') // Ersetze &nbsp;
.replace(/&auml;/g, 'ä')
.replace(/&ouml;/g, 'ö')
.replace(/&uuml;/g, 'ü')
.replace(/&Auml;/g, 'Ä')
.replace(/&Ouml;/g, 'Ö')
.replace(/&Uuml;/g, 'Ü')
.replace(/&szlig;/g, 'ß')
.trim();
// Konvertiere Datum von DD.MM.YYYY zu YYYY-MM-DD
const [day, month, yearPart] = dateStr.split('.');
const isoDate = `${yearPart}-${month}-${day}`;
if (dayName && dayName.length > 0) {
liturgicalDays.push({
date: isoDate,
dayName: dayName
});
}
}
if (liturgicalDays.length === 0) {
return res.status(500).json({ message: 'Keine liturgischen Tage gefunden. Möglicherweise hat sich das HTML-Format geändert.' });
}
// Speichere oder aktualisiere die Einträge
for (const day of liturgicalDays) {
await LiturgicalDay.upsert({
date: day.date,
dayName: day.dayName
});
}
res.status(200).json({
message: `${liturgicalDays.length} liturgische Tage für ${year} erfolgreich geladen`,
count: liturgicalDays.length
});
} catch (error) {
console.error('Fehler beim Laden der liturgischen Tage:', error);
if (error.response && error.response.status === 404) {
return res.status(404).json({ message: `Liturgischer Kalender für ${year} nicht gefunden` });
}
res.status(500).json({ message: 'Fehler beim Laden der liturgischen Tage', error: error.message });
}
};
// Einzelnen Tag erstellen
const createLiturgicalDay = async (req, res) => {
try {
const day = await LiturgicalDay.create(req.body);
res.status(201).json(day);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Erstellen des liturgischen Tags' });
}
};
// Tag löschen
const deleteLiturgicalDay = async (req, res) => {
try {
const { id } = req.params;
const deleted = await LiturgicalDay.destroy({
where: { id }
});
if (deleted) {
res.status(200).json({ message: 'Liturgischer Tag erfolgreich gelöscht' });
} else {
res.status(404).json({ message: 'Liturgischer Tag nicht gefunden' });
}
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Löschen des liturgischen Tags' });
}
};
module.exports = {
getAllLiturgicalDays,
getLiturgicalDayNames,
loadLiturgicalYear,
createLiturgicalDay,
deleteLiturgicalDay
};

View File

@@ -1,31 +1,12 @@
const { MenuItem } = require('../models'); const MenuDataService = require('../services/MenuDataService');
const fetchMenuData = require('../utils/fetchMenuData'); const ErrorHandler = require('../utils/ErrorHandler');
exports.getMenuData = async (req, res) => { exports.getMenuData = ErrorHandler.asyncHandler(async (req, res) => {
try { const menuData = await MenuDataService.getMenuData();
const menuData = await fetchMenuData(); ErrorHandler.successResponse(res, menuData, 'Menü-Daten erfolgreich abgerufen');
res.json(menuData); });
} catch (error) {
console.error('getMenuData:', error);
res.status(500).json({ error: 'Menü konnte nicht geladen werden (Datenbank nicht erreichbar).' });
}
};
exports.saveMenuData = async (req, res) => { exports.saveMenuData = ErrorHandler.asyncHandler(async (req, res) => {
try { const result = await MenuDataService.saveMenuData(req.body);
const menuData = req.body; ErrorHandler.successResponse(res, result, result.message);
const adjustedMenuData = menuData.map(item => { });
item.parent_id = item.parent_id < 0 ? null : item.parent_id;
return item;
})
.sort((a, b) => (a.parent_id === null ? -1 : 1) - (b.parent_id === null ? -1 : 1));
await MenuItem.destroy({ where: {} });
for (const item of adjustedMenuData) {
await MenuItem.create(item);
}
res.status(200).send('Menü-Daten erfolgreich gespeichert');
} catch (error) {
console.error('Fehler beim Speichern der Menü-Daten:', error);
res.status(500).send('Fehler beim Speichern der Menü-Daten');
}
};

View File

@@ -1,48 +1,27 @@
// controllers/pageController.js const PageService = require('../services/PageService');
const { Page } = require('../models'); const ErrorHandler = require('../utils/ErrorHandler');
exports.getMenuData = async (req, res) => { exports.getMenuData = ErrorHandler.asyncHandler(async (req, res) => {
try { const pages = await PageService.getAllPages();
const pages = await Page.findAll({ ErrorHandler.successResponse(res, pages, 'Seiten erfolgreich abgerufen');
attributes: ['link', 'name'] });
});
res.json(pages);
} catch (error) {
console.error('Fehler beim Abrufen der Seiten:', error);
res.status(500).json({ message: 'Fehler beim Abrufen der Seiten' });
}
};
exports.getPageContent = async (req, res) => { exports.getPageContent = ErrorHandler.asyncHandler(async (req, res) => {
try { const result = await PageService.getPageContent(req.query.link);
const page = await Page.findOne({ ErrorHandler.successResponse(res, result, 'Seiteninhalt erfolgreich abgerufen');
where: { link: req.query.link } });
});
if (page) {
res.json({ content: page.content });
} else {
res.json({ content: "" });
}
} catch (error) {
console.error('Fehler beim Laden des Seiteninhalts:', error);
res.status(500).json({ message: 'Fehler beim Laden des Seiteninhalts' });
}
};
exports.savePageContent = async (req, res) => { exports.savePageContent = ErrorHandler.asyncHandler(async (req, res) => {
try { const result = await PageService.savePageContent(req.body);
const { link, name, content } = req.body; ErrorHandler.successResponse(res, result, result.message);
let page = await Page.findOne({ where: { link } }); });
if (page) {
page.content = content; exports.getPageById = ErrorHandler.asyncHandler(async (req, res) => {
page.name = name; const page = await PageService.getPageById(req.params.id);
} else { ErrorHandler.successResponse(res, page, 'Seite erfolgreich abgerufen');
page = await Page.create({ link, name, content }); });
}
await page.save(); exports.deletePage = ErrorHandler.asyncHandler(async (req, res) => {
res.json({ message: 'Seiteninhalt gespeichert', page }); const result = await PageService.deletePage(req.params.id);
} catch (error) { ErrorHandler.successResponse(res, result, result.message);
console.error('Fehler beim Speichern des Seiteninhalts:', error); });
res.status(500).json({ message: 'Fehler beim Speichern des Seiteninhalts' });
}
};

View File

@@ -1,104 +1,42 @@
const { User } = require('../models'); const UserService = require('../services/UserService');
const UserValidator = require('../validators/UserValidator');
const ErrorHandler = require('../utils/ErrorHandler');
exports.getAllUsers = async (req, res) => { exports.getAllUsers = ErrorHandler.asyncHandler(async (req, res) => {
try { const users = await UserService.getAllUsers();
const users = await User.findAll({ ErrorHandler.successResponse(res, users, 'Benutzer erfolgreich abgerufen');
order: [['name', 'ASC']], });
attributes: ['id', 'name', 'email', 'active', 'created_at'] // Passwort ausschließen
});
res.status(200).json(users);
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({ message: 'Error fetching users' });
}
};
exports.getUserById = async (req, res) => { exports.getUserById = ErrorHandler.asyncHandler(async (req, res) => {
try { UserValidator.validateId(req.params.id);
const user = await User.findByPk(req.params.id, { const user = await UserService.getUserById(req.params.id);
attributes: ['id', 'name', 'email', 'active', 'created_at'] // Passwort ausschließen ErrorHandler.successResponse(res, user, 'Benutzer erfolgreich abgerufen');
}); });
if (user) {
res.status(200).json(user);
} else {
res.status(404).json({ message: 'User not found' });
}
} catch (error) {
console.error('Error fetching user:', error);
res.status(500).json({ message: 'Error fetching user' });
}
};
exports.createUser = async (req, res) => { exports.createUser = ErrorHandler.asyncHandler(async (req, res) => {
try { UserValidator.validateCreateUser(req.body);
const user = await User.create(req.body); const user = await UserService.createUser(req.body);
ErrorHandler.successResponse(res, user, 'Benutzer erfolgreich erstellt', 201);
// Sichere User-Daten zurückgeben (ohne Passwort) });
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at
};
res.status(201).json(safeUser);
} catch (error) {
console.error('Error creating user:', error);
res.status(500).json({ message: 'Error creating user' });
}
};
exports.updateUser = async (req, res) => { exports.updateUser = ErrorHandler.asyncHandler(async (req, res) => {
try { UserValidator.validateId(req.params.id);
const user = await User.findByPk(req.params.id); UserValidator.validateUpdateUser(req.body);
if (user) { const user = await UserService.updateUser(req.params.id, req.body);
// Erstelle eine Kopie der Request-Daten ohne sensible Felder ErrorHandler.successResponse(res, user, 'Benutzer erfolgreich aktualisiert');
const updateData = { ...req.body }; });
// Entferne sensible Felder, die niemals über diese Route geändert werden dürfen
delete updateData.password;
delete updateData.id;
delete updateData.created_at;
// Setze updated_at auf aktuelle Zeit
updateData.updated_at = new Date();
// Logging für Debugging
console.log('Updating user:', req.params.id, 'with data:', updateData);
await user.update(updateData);
// Sichere User-Daten zurückgeben (ohne Passwort)
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at
};
res.status(200).json(safeUser);
} else {
res.status(404).json({ message: 'User not found' });
}
} catch (error) {
console.error('Error updating user:', error);
res.status(500).json({ message: 'Error updating user' });
}
};
exports.deleteUser = async (req, res) => { exports.deleteUser = ErrorHandler.asyncHandler(async (req, res) => {
try { UserValidator.validateId(req.params.id);
const user = await User.findByPk(req.params.id); await UserService.deleteUser(req.params.id);
if (user) { ErrorHandler.successResponse(res, null, 'Benutzer erfolgreich gelöscht');
await user.destroy(); });
res.status(200).json({ message: 'User deleted successfully' });
} else { // Neue Route für Passwort-Änderung
res.status(404).json({ message: 'User not found' }); exports.changePassword = ErrorHandler.asyncHandler(async (req, res) => {
} const { currentPassword, newPassword } = req.body;
} catch (error) { UserValidator.validateId(req.params.id);
console.error('Error deleting user:', error); UserValidator.validatePasswordChange(currentPassword, newPassword);
res.status(500).json({ message: 'Error deleting user' }); await UserService.changePassword(req.params.id, currentPassword, newPassword);
} ErrorHandler.successResponse(res, null, 'Passwort erfolgreich geändert');
}; });

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +0,0 @@
const { WorshipLeader } = require('../models');
const { Op } = require('sequelize');
function normalizeLeaderPayload(body) {
const code = String(body.code || '').trim();
const name = String(body.name || '').trim();
const aliases = String(body.aliases || '').trim();
const active = body.active === undefined ? true : !!body.active;
return { code, name, aliases, active };
}
exports.getAllWorshipLeaders = async (req, res) => {
try {
const includeInactive = String(req.query?.includeInactive || '').toLowerCase();
const wantsInactive = includeInactive === '1' || includeInactive === 'true' || includeInactive === 'yes';
const where = wantsInactive ? undefined : { active: true };
const leaders = await WorshipLeader.findAll({
where,
order: [['code', 'ASC']],
});
res.json(leaders);
} catch (error) {
console.error('getAllWorshipLeaders:', error);
res.status(500).json({ error: 'Failed to fetch worship leaders' });
}
};
exports.createWorshipLeader = async (req, res) => {
try {
const payload = normalizeLeaderPayload(req.body || {});
if (!payload.code || !payload.name) {
return res.status(400).json({ message: 'code und name sind Pflichtfelder.' });
}
const existing = await WorshipLeader.findOne({ where: { code: payload.code } });
if (existing) {
return res.status(409).json({ message: `Kürzel "${payload.code}" existiert bereits.` });
}
const created = await WorshipLeader.create(payload);
res.status(201).json(created);
} catch (error) {
console.error('createWorshipLeader:', error);
res.status(500).json({ error: 'Failed to create worship leader' });
}
};
exports.updateWorshipLeader = async (req, res) => {
try {
const { id } = req.params;
const leader = await WorshipLeader.findByPk(id);
if (!leader) {
return res.status(404).json({ message: 'Worship leader not found' });
}
const payload = normalizeLeaderPayload(req.body || {});
if (!payload.code || !payload.name) {
return res.status(400).json({ message: 'code und name sind Pflichtfelder.' });
}
const codeClash = await WorshipLeader.findOne({
where: { code: payload.code, id: { [Op.ne]: id } },
});
if (codeClash) {
return res.status(409).json({ message: `Kürzel "${payload.code}" existiert bereits.` });
}
await leader.update(payload);
res.json(leader);
} catch (error) {
console.error('updateWorshipLeader:', error);
res.status(500).json({ error: 'Failed to update worship leader' });
}
};
exports.deleteWorshipLeader = async (req, res) => {
try {
const { id } = req.params;
const deleted = await WorshipLeader.destroy({ where: { id } });
if (!deleted) {
return res.status(404).json({ message: 'Worship leader not found' });
}
res.status(204).json();
} catch (error) {
console.error('deleteWorshipLeader:', error);
res.status(500).json({ error: 'Failed to delete worship leader' });
}
};

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Farben
GREEN="\033[0;32m"
RED="\033[0;31m"
NC="\033[0m"
log() { echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] $*${NC}"; }
err() { echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] $*${NC}" 1>&2; }
ensure_command() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1
}
bootstrap_node() {
# In non-interactive SSH sessions, node/npm might not be on PATH (e.g. nvm in .bashrc).
if ensure_command npm && ensure_command node; then
return 0
fi
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$HOME/.nvm/nvm.sh"
# Prefer default alias if configured, otherwise keep current.
nvm use --silent default >/dev/null 2>&1 || true
fi
}
bootstrap_node
if ! ensure_command git; then err "git not found in PATH"; exit 127; fi
if ! ensure_command npm; then err "npm not found in PATH"; exit 127; fi
log "Fetching latest changes..."
git fetch --all --prune || { err "git fetch failed"; exit 1; }
log "Cleaning generated frontend artifacts..."
git restore -- package-lock.json package.json 2>/dev/null || true
git restore -- public/index.html 2>/dev/null || true
git clean -fd -- dist public/assets || { err "cleanup failed"; exit 1; }
log "Pulling latest changes..."
git pull --ff-only || { err "git pull failed"; exit 1; }
log "Installing dependencies..."
npm ci || npm install || { err "npm install failed"; exit 1; }
log "Building frontend..."
npm run build || { err "build failed"; exit 1; }
log "Copying dist -> public..."
mkdir -p public || true
cp -R dist/* public/ || { err "copy dist failed"; exit 1; }
log "Restarting service miriamgemeinde..."
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
systemctl restart miriamgemeinde || { err "service restart failed"; exit 1; }
else
# Non-interactive deploys (CI) must not prompt for a sudo password.
# Configure on the server (recommended):
# sudo visudo -f /etc/sudoers.d/miriamgemeinde
# torsten ALL=NOPASSWD:/bin/systemctl restart miriamgemeinde,/bin/systemctl status miriamgemeinde
sudo -n systemctl restart miriamgemeinde || {
err "service restart failed (no password prompt). Configure passwordless sudo for 'systemctl restart miriamgemeinde' or run deploy as root."
exit 1
}
fi
log "Deployment completed successfully."

View File

@@ -1,224 +0,0 @@
# Konzept: Modernisierung von Design und Bedienbarkeit
**Projekt:** Evangelische Miriamgemeinde Frankfurt (Vue.js-Webauftritt)
**Stand:** April 2026
**Ziel:** Zeitgemäße, klare Oberfläche mit hoher Vertrauenswürdigkeit; kirchlich-seriös, ohne „Startup-Optik“.
---
## 1. Zielbild und Leitlinien
### 1.1 Positionierung
Die Website ist **Informations- und Gemeinschaftsangebot** einer evangelischen Gemeinde. Sie soll:
- **verlässlich und ruhig** wirken (kein visuelles „Rauschen“),
- **inhaltlich im Vordergrund** stehen (Typografie, Lesbarkeit, klare Hierarchie),
- **digital souverän** wirken (gute Struktur, schnelle Orientierung, respektvolle Hilfen für alle Nutzergruppen).
### 1.2 Nicht verhandelbar: EKHN-Violett
Die **Grundfarbe EKHN-Violett** bleibt die primäre Markenfarbe. Im Code aktuell u. a. als `#9400ff` mit Hover `#7a00d1` genutzt (Navigation). Diese Farbe wird **nicht ersetzt oder „neu interpretiert“**.
- Sie wird als **CSS-Design-Token** zentral definiert (z. B. `--color-ekhn-violet`, `--color-ekhn-violet-hover`), damit alle Komponenten konsistent darauf zugreifen.
- **Abstufungen** (heller für Hintergründe, transparenter für Overlays) sind **zulässig**, solange die wahrgenommene Marke **dieselbe Violett-Identität** bleibt.
- Kontrast zu Text und Icons muss **WCAG-konform** sein (siehe Abschnitt 7).
### 1.3 Seriosität vs. Modernität
| Modern (gewünscht) | Vermeiden (für kirchlichen Kontext) |
|--------------------|-------------------------------------|
| Klares Raster, viel Weißraum | Neon-Verläufe, Spielereien |
| Ruhige, lesbare Schrift | Display-Fonts, übertriebene Größen |
| Deutliche Fokuszustände (Tastatur) | Aggressive Animationen |
| Einheitliche Komponenten | Zufällige Abstände und Stile pro Seite |
| Verständliche Navigation | „Experimentelle“ Menüs ohne klare Labels |
**Leitmotiv:** *Ruhige Sachlichkeit mit warmer, einladender Sprache in der UI (Beschriftungen, Fehlermeldungen, leere Zustände).*
---
## 2. Kurze Ist-Analyse (Ausgangslage)
Aus dem aktuellen Aufbau (u. a. `AppComponent.vue`, `NavbarComponent.vue`, `HeaderComponent.vue`, `FooterComponent.vue`):
- **Typografie:** durchgängig `Arial, sans-serif` funktional, aber wenig Profil; keine skalierbare Typo-Skala.
- **Layout:** starre `min-width: 1000px` in der Hauptspalte begünstigt horizontales Scrollen auf Tablets/kleineren Viewports; Zwei-Spalten-Logik mit Breakpoints ist vorhanden, sollte aber **inhaltlich und technisch** weiterentwickelt werden.
- **Farben:** Violett in der Navigation; Footer dunkelblau (`#0b1735`); rechte Spalte hellblau (`#d9e2f3`); Header mit Schlagschatten in Lavendeltönen teils **uneinheitlich** zur Markenfarbe.
- **Navigation:** Hamburger/Menü-Button unter 768px; Dropdowns mit Hover auf Touch-Geräten und für Tastaturnutzer ist hier **Verbesserungspotenzial** (Fokus, ARIA, Touch-Targets).
- **Footer:** Login-Link in Grau auf dunklem Grund **Kontrast** prüfen und ggf. anpassen (ohne Marke zu verändern).
Diese Punkte fließen als konkrete Maßnahmen in die Phasenplanung (Abschnitt 9) ein.
---
## 3. Design-System: Farben
### 3.1 Primär (unverändert)
| Token (Vorschlag) | Verwendung | Hex (Ist) |
|-------------------|------------|-----------|
| `--color-brand-primary` | Navigationsleiste, primäre Buttons, aktive Zustände | `#9400ff` |
| `--color-brand-primary-hover` | Hover, aktive Menüpunkte | `#7a00d1` |
*Hinweis:* Falls das offizielle EKHN-Handbuch eine minimal abweichende Hex-Angabe vorsieht, **eine** kanonische Quelle festlegen und nur diese verwenden weiterhin **kein** Wechsel zu einer anderen Farbfamilie.
### 3.2 Neutrale Flächen (ergänzend, nicht markenersetzend)
- **Hintergrund Seite:** `#ffffff` oder sehr helles Neutral (`#f8f9fb`), konsistent.
- **Sekundärflächen** (Karten, rechte Spalte, Infoboxen): dezentes Grau oder ein **sehr zurückhaltendes Violett-Grau** (z. B. Mischung aus Weiß mit 36 % Primärfarbe), damit die Seite **ruhig** bleibt und nicht „bunt“ wirkt.
- **Text:** nahezu schwarz für Fließtext (`#1a1a1a` bis `#222`), sekundäre Texte etwas heller immer mit Kontrastprüfung.
### 3.3 Akzent (optional, sparsam)
- **Links** im Fließtext: z. B. unterstrichen oder klar farbig abgesetzt; Primärviolett oder eine **eine** abgestimmte dunklere Violett-Nuance für Lesbarkeit auf Weiß.
- **Erfolg/Warnung/Fehler:** Standard-Semantik (Grün/Gelb/Rot) nur für Status **nicht** als neue Hauptfarbe neben dem Violett.
### 3.4 Footer
- Dunkler Footer kann bleiben; **Links und Fokus** müssen gut lesbar sein. Primärviolett für Hover/Fokus auf dunklem Grund nur, wenn der Kontrast stimmt sonch neutrale helle Linkfarbe + sichtbarer Fokusring.
---
## 4. Typografie
### 4.1 Schriftwahl
- **Primärschrift:** eine gut lesbare **System- oder Webschrift** mit neutral-seriösem Charakter, z. B.:
- *Source Sans 3*, *Inter*, *Open Sans* oder **beibehaltene Arial** nach einheitlicher Skala Entscheidung in Phase 1 an **Performance** und **Corporate-Vorgaben** binden.
- **Überschriften:** dieselbe Familie mit klarer Gewichtsstaffelung (z. B. 600/700), keine verspielten Display-Schnitte.
### 4.2 Skala (Beispiel)
| Stufe | Verwendung | Größe (Orientierung, rem) |
|-------|------------|---------------------------|
| H1 | Seitentitel (nicht auf jeder Unterseite doppelt mit Logo-Text kollidieren) | `1.752rem` |
| H2 | Abschnitte | `1.351.5rem` |
| H3 | Unterabschnitte | `1.151.25rem` |
| Body | Fließtext | `1rem`, Zeilenlänge max. ca. 6575 Zeichen |
| Klein | Meta, Fußnoten | `0.875rem` mit ausreichend Kontrast |
### 4.3 Regeln
- **Keine** reine Großschreibung für lange Menütexte.
- **Zeilenabstand** für Fließtext mindestens ca. 1,5.
- **Kontrast** von Überschriften und Text zu Hintergrund einhalten (WCAG 2.1 AA).
---
## 5. Layout und Raster
### 5.1 Container
- Maximalbreite für Lesbarkeit (z. B. `min(100%, 72rem)`) mit **symmetrischem Innenabstand**.
- **Keine** feste `min-width` im vierstelligen Pixelbereich ohne Scroll-Alternative; stattdessen **flexibles Grid** + sinnvolle Mindestbreiten nur dort, wo nötig (Tabellen).
### 5.2 Breakpoints (Orientierung)
- **Mobil:** &lt; 640px eine Spalte, Navigation als klares Overlay oder ausklappbare Liste mit großen Touch-Zielen.
- **Tablet:** 6401024px ggf. weiterhin eine Spalte oder kompakte Sidebar.
- **Desktop:** &gt; 1024px Zwei-Spalten-Layout optional; rechte Spalte für Bilder/Termine **nicht** zwingend über volle Höhe „eingesperrt“, wenn das inhaltlich sinnvoller ist.
### 5.3 Weißraum
- Einheitliches Spacing-System (z. B. Vielfache von 4px oder 0,25rem): Abstände zwischen Blöcken, in Karten, zwischen Formularfeldern.
---
## 6. Navigation und Bedienung
### 6.1 Hauptnavigation
- **Desktop:** horizontale Leiste mit Vollfarbe EKHN-Violett; aktiver Eintrag **deutlich** (Unterstreichung, Hintergrund oder starker Kontrast weiterhin im Violett-System).
- **Touch:** Menüpunkte mindestens ca. **44×44 px** Klickfläche.
- **Untermenüs:** Hover für Maus; für Tastatur **Escape** schließt; **Fokus** sichtbar im gesamten Menübaum.
- **Mobile:** „Menü“-Button durch **Icon + Text** oder klares Label; Animationen **kurz** (&lt; 200ms).
### 6.2 Orientierung
- Optional: **Brotkrumen** bei tieferen Seiten (Gemeindeleben → Gruppen → …), dezent unterhalb des Headers.
- Seitentitel konsistent: eine H1 pro Seite, semantisch korrekt.
### 6.3 Footer
- Impressum/Datenschutz bleiben gut auffindbar; gleiche visuelle Gewichtung wie bisher, mit verbessertem Kontrast und Fokus.
---
## 7. Barrierefreiheit (WCAG 2.1 Level AA als Ziel)
- **Kontrast:** Text auf Violett/Weiß/Grau messen (Tools: axe, Lighthouse, WebAIM Contrast Checker).
- **Tastatur:** alle interaktiven Elemente erreichbar; sichtbarer `:focus-visible`.
- **Screenreader:** Landmarks (`header`, `nav`, `main`, `footer`), Überschriftenhierarchie, `aria-expanded` für Untermenüs, sinnvolle `alt`-Texte bei Bildern.
- **Bewegung:** `prefers-reduced-motion` respektieren (Animationen abschwächen oder deaktivieren).
---
## 8. Komponentenbibliothek (schrittweise)
Einheitliche Bausteine reduzieren Streuung und erleichtern Wartung:
| Komponente | Anforderungen |
|------------|----------------|
| **Primärbutton** | Violett-Hintergrund, weißer Text, Hover, disabled-Zustand, Fokusring |
| **Sekundärbutton** | Outline in Violett oder neutral, gleiche Höhe wie Primär |
| **Karte** (Termine, News) | Klarer Titel, Datum, Link „weiterlesen“, ruhiger Schatten oder Rahmen |
| **Formularfelder** | Labels sichtbar, Fehler näch am Feld, keine rein farblichen Fehlerhinweise |
| **Tabellen** (Admin) | Zeilenwechsel, ausreichend Zellpadding, horizontales Scrollen auf schmalen Screens |
Technisch: zentrale **CSS-Variablen** + ggf. wiederverwendbare Vue-Komponenten oder Utility-Klassen ohne das Projekt unnötig zu überfrachten.
---
## 9. Umsetzung in Phasen
### Phase 1 Fundament (geringes Risiko, hoher Nutzen)
- Design-Tokens (Farben, Abstände, Schriftgrößen) in einer **globalen** Styleschicht.
- Entfernen/Ersetzen problematischer Layout-Regeln (`min-width` Hauptspalte), **Responsive** testen.
- Typografie-Skala und Basisabstände vereinheitlichen.
- Kontrast Footer/Links prüfen und anpassen.
### Phase 2 Navigation & Chrome
- Navbar optisch verfeinern (Padding, aktive Zustände, Touch) bei **unverändertem** `#9400ff` / Hover.
- Header (Titelzeile): Schlagschatten/Lavendel **an Markensystem anbinden** oder reduzieren für seriösere Wirkung.
- Optional Brotkrumen für tiefe Seiten.
### Phase 3 Inhaltsmodule
- Termine, Gottesdienste, Kontakt: Kartenlayout, konsistente Datumdarstellung (`date-fns` ist bereits im Projekt).
- Bilder: feste Aspect-Ratios oder `object-fit`, damit keine Layout-Sprünge entstehen.
### Phase 4 Feinschliff
- Mikro-Interaktionen (Hover, Fokus) konsistent.
- Performance: Schriftarten, Bildgrößen, Lazy Loading wo sinnvoll.
- Finale Accessibility-Prüfung.
---
## 10. Erfolgskriterien (messbar / reviewbar)
- Keine horizontale Scrollbarkeit bei Standard-Viewports durch feste Mindestbreiten.
- Lighthouse-Accessibility-Score deutlich verbessert (Ziel: **≥ 90**, wo technisch möglich).
- **Manuelle** Tastatur- und Screenreader-Stichprobe auf Startseite, Navigation, einem Formular.
- **Visuelles Review** mit 23 Stakeholdern: „wirkt seriös, klar, kirchlich passend“ **ohne** neue Hauptfarbe neben EKHN-Violett.
---
## 11. Explizite Nicht-Ziele
- Kein Rebranding und kein Ersatz der Primärfarbe.
- Kein „Gamification“-Design oder verspielte Illustrationen als Hauptstil.
- Keine Einführung schwerer UI-Frameworks nur wegen Optik, wenn das Projekt schlank bleiben soll (abwägen mit Wartbarkeit).
---
## 12. Nächster Schritt
Umsetzung beginnt mit **Phase 1**: globale Tokens + Layout-Fixes + Kontrast. Dieses Dokument dient als **Referenz** für alle weiteren UI-Änderungen und sollte bei größeren Designentscheidungen aktualisiert werden.
---
*Dokument erstellt als Arbeitsgrundlage für die Modernisierung; bei Abweichungen von offiziellen EKHN-CD-Vorgaben immer die gültige kirchliche Markenrichtlinie Vorrang haben.*

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>Miriamgemeinde</title>
</head>
<body>
<noscript>
<strong>
Diese Website funktioniert leider nicht richtig, wenn JavaScript deaktiviert ist. Bitte aktivieren Sie
JavaScript, um fortzufahren.
</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,28 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.createTable('liturgical_days', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
date: {
type: Sequelize.DATEONLY,
allowNull: false,
unique: true
},
dayName: {
type: Sequelize.STRING,
allowNull: false
}
});
},
async down (queryInterface, Sequelize) {
await queryInterface.dropTable('liturgical_days');
}
};

View File

@@ -1,16 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('worships', 'organ_playing', {
type: Sequelize.STRING,
allowNull: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('worships', 'organ_playing');
}
};

View File

@@ -1,17 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('worships', 'approved', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('worships', 'approved');
}
};

View File

@@ -1,47 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('worship_leaders', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
code: {
type: Sequelize.STRING(32),
allowNull: false,
unique: true,
},
name: {
type: Sequelize.STRING(255),
allowNull: false,
},
aliases: {
type: Sequelize.STRING(512),
allowNull: false,
defaultValue: '',
},
active: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
},
});
},
async down(queryInterface) {
await queryInterface.dropTable('worship_leaders');
},
};

View File

@@ -25,10 +25,6 @@ module.exports = (sequelize) => {
email: { email: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true allowNull: true
},
expiryDate: {
type: DataTypes.DATEONLY,
allowNull: true
} }
}, { }, {
tableName: 'contact_persons', tableName: 'contact_persons',

View File

@@ -1,24 +0,0 @@
module.exports = (sequelize, DataTypes) => {
const LiturgicalDay = sequelize.define('LiturgicalDay', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
date: {
type: DataTypes.DATEONLY,
allowNull: false,
unique: true
},
dayName: {
type: DataTypes.STRING,
allowNull: false
}
}, {
tableName: 'liturgical_days',
timestamps: false
});
return LiturgicalDay;
};

View File

@@ -61,17 +61,6 @@ module.exports = (sequelize) => {
allowNull: true, allowNull: true,
field: 'sacristan_service' field: 'sacristan_service'
}, },
organPlaying: {
type: DataTypes.STRING,
allowNull: true,
field: 'organ_playing'
},
approved: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
field: 'approved'
},
}, { }, {
tableName: 'worships', tableName: 'worships',
timestamps: true timestamps: true

View File

@@ -1,32 +0,0 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const WorshipLeader = sequelize.define('WorshipLeader', {
code: {
type: DataTypes.STRING(32),
allowNull: false,
unique: true,
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
aliases: {
// Comma-separated list of alternative codes (kept simple to avoid join tables).
type: DataTypes.STRING(512),
allowNull: true,
defaultValue: '',
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
}, {
tableName: 'worship_leaders',
timestamps: true,
});
return WorshipLeader;
};

View File

@@ -3,11 +3,20 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const Sequelize = require('sequelize'); const Sequelize = require('sequelize');
const sequelize = require('../config/database');
const basename = path.basename(__filename); const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];
const db = {}; const db = {};
fs.readdirSync(__dirname) let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, config);
}
fs
.readdirSync(__dirname)
.filter(file => { .filter(file => {
return ( return (
file.indexOf('.') !== 0 && file.indexOf('.') !== 0 &&

32145
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,81 +3,81 @@
"version": "1.1.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "serve": "vue-cli-service serve",
"build": "vite build && npm run copy-dist", "build": "vue-cli-service build && npm run copy-dist",
"preview": "vite preview",
"copy-dist": "cp -r dist/* public/", "copy-dist": "cp -r dist/* public/",
"lint": "eslint src --ext .js,.vue", "lint": "vue-cli-service lint"
"lint:all": "eslint . --ext .js,.vue"
}, },
"dependencies": { "dependencies": {
"@iconoir/vue": "^7.7.0", "@iconoir/vue": "^7.7.0",
"@tiptap/extension-bold": "^3.22.2", "@tiptap/extension-bold": "^2.4.0",
"@tiptap/extension-bullet-list": "^3.22.2", "@tiptap/extension-bullet-list": "^2.4.0",
"@tiptap/extension-color": "^3.22.2", "@tiptap/extension-color": "^2.4.0",
"@tiptap/extension-heading": "^3.22.2", "@tiptap/extension-heading": "^2.4.0",
"@tiptap/extension-italic": "^3.22.2", "@tiptap/extension-italic": "^2.4.0",
"@tiptap/extension-link": "^3.22.2", "@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-ordered-list": "^3.22.2", "@tiptap/extension-ordered-list": "^2.4.0",
"@tiptap/extension-strike": "^3.22.2", "@tiptap/extension-strike": "^2.4.0",
"@tiptap/extension-table": "^3.22.2", "@tiptap/extension-table": "^2.4.0",
"@tiptap/extension-table-cell": "^3.22.2", "@tiptap/extension-table-cell": "^2.4.0",
"@tiptap/extension-table-header": "^3.22.2", "@tiptap/extension-table-header": "^2.4.0",
"@tiptap/extension-table-row": "^3.22.2", "@tiptap/extension-table-row": "^2.4.0",
"@tiptap/extension-text-style": "^3.22.2", "@tiptap/extension-text-style": "^2.4.0",
"@tiptap/extension-underline": "^3.22.2", "@tiptap/extension-underline": "^2.4.0",
"@tiptap/starter-kit": "^3.22.2", "@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^3.22.2", "@tiptap/vue-3": "^2.4.0",
"@xmldom/xmldom": "^0.8.12", "@vue/cli": "^5.0.8",
"axios": "^1.7.2", "axios": "^1.7.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"core-js": "^3.8.3", "core-js": "^3.8.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"crypto": "^1.0.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"docx": "^9.5.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^5.2.1", "express": "^4.19.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1", "moment": "^2.30.1",
"mammoth": "^1.11.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1", "mysql2": "^3.10.1",
"nodemailer": "^7.0.6", "nodemailer": "^7.0.6",
"pdf-parse": "^1.1.1", "nodemon": "^3.1.3",
"sequelize": "^6.37.3", "sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2", "sequelize-cli": "^6.6.2",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"vm-browserify": "^1.1.2", "vm-browserify": "^1.1.2",
"vue": "^3.4.30", "vue": "^3.4.30",
"vue-multiselect": "^3.0.0", "vue-multiselect": "^3.0.0",
"vue-quill-editor": "^3.0.6",
"vue-router": "^4.3.3", "vue-router": "^4.3.3",
"vuex": "^4.0.2" "vuex": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@babel/core": "^7.12.16",
"eslint": "^8.57.0", "@babel/eslint-parser": "^7.12.16",
"eslint-plugin-vue": "^9.23.0", "@vue/cli-plugin-babel": "~5.0.0",
"nodemon": "^3.1.3", "@vue/cli-plugin-eslint": "~5.0.0",
"vite": "^8.0.7", "@vue/cli-service": "~5.0.0",
"vue-eslint-parser": "^9.4.3" "crypto-browserify": "^3.12.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"stream-browserify": "^3.0.0",
"webpack": "^5.92.0"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,
"env": { "env": {
"browser": true,
"es2021": true,
"node": true "node": true
}, },
"extends": [ "extends": [
"plugin:vue/vue3-essential", "plugin:vue/vue3-essential",
"eslint:recommended" "eslint:recommended"
], ],
"parser": "vue-eslint-parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": 2022, "parser": "@babel/eslint-parser"
"sourceType": "module"
}, },
"rules": {} "rules": {}
}, },

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.register[data-v-4256bbfa]{max-width:400px;margin:auto}form[data-v-4256bbfa]{display:flex;flex-direction:column}label[data-v-4256bbfa]{margin-top:10px}button[data-v-4256bbfa]{margin-top:20px}

View File

@@ -0,0 +1 @@
.privacy-policy[data-v-0c3320ea]{max-width:800px;margin:auto;padding:20px}h1[data-v-0c3320ea],h2[data-v-0c3320ea],h3[data-v-0c3320ea],h4[data-v-0c3320ea],h5[data-v-0c3320ea]{margin-top:20px;color:#333}p[data-v-0c3320ea]{line-height:1.6}ul[data-v-0c3320ea]{margin:10px 0;padding-left:20px}ul li[data-v-0c3320ea]{list-style-type:disc}a[data-v-0c3320ea]{color:#007bff;text-decoration:none}a[data-v-0c3320ea]:hover{text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}.previewinfo[data-v-9a71cbf6]{background-color:#000;color:#d00000;position:absolute;top:93px;left:0;padding:2px 10px;font-weight:700}

View File

@@ -0,0 +1 @@
.upload-files[data-v-f2694614]{width:100%;margin:auto}.upload-files div[data-v-f2694614]{margin-bottom:10px}.file-list[data-v-f2694614]{list-style-type:none;padding:0;margin-top:20px}.file-list li[data-v-f2694614]{border-bottom:1px solid #ddd;padding:10px 0}.file-info[data-v-f2694614]{display:flex;justify-content:space-between;cursor:pointer}.file-title[data-v-f2694614]{font-weight:700}.file-name[data-v-f2694614]{color:#555}.file-date[data-v-f2694614]{color:#888}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.right-column[data-v-12df4c86]{background-color:#d9e2f3}.right-column h2[data-v-12df4c86]{text-align:center;color:#000}.right-column img[data-v-12df4c86]{display:block;margin:0 auto;max-width:100%;height:auto}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.menu-management[data-v-0e6a0522]{width:100%;margin:auto}.button-container[data-v-0e6a0522]{display:inline-flex;gap:10px;margin-bottom:20px}.tree-view[data-v-0e6a0522]{margin-top:20px}.tree-view ul[data-v-0e6a0522]{list-style-type:none;padding:0}.tree-view li[data-v-0e6a0522]{margin-bottom:5px;padding-left:20px}.tree-view .menu-item[data-v-0e6a0522]{display:inline-flex;width:100%;justify-content:space-between;align-items:center}.tree-view span[data-v-0e6a0522]{cursor:pointer;color:#000}.tree-view button[data-v-0e6a0522]{border:none;height:1.6em;padding:0 .5em;margin:1px;border-radius:5px}.tree-view span[data-v-0e6a0522]:hover{text-decoration:underline}.edit-form[data-v-0e6a0522]{margin-top:20px}.edit-form label[data-v-0e6a0522]{display:block;margin-bottom:5px;font-weight:700}.edit-form input[data-v-0e6a0522]:not([type=checkbox]){display:block;margin-bottom:10px}.edit-form .checkbox-container[data-v-0e6a0522]{display:flex;flex-direction:column;margin-right:10px}.edit-form .order-id[data-v-0e6a0522]{width:50px}.edit-form button[data-v-0e6a0522]{margin-top:5px}

View File

@@ -0,0 +1 @@
.position-management[data-v-1684a375]{max-width:600px;margin:auto;padding:20px;border:1px solid #ccc;border-radius:5px}form[data-v-1684a375]{display:flex;flex-direction:column;margin-bottom:20px}label[data-v-1684a375]{margin-top:10px}input[data-v-1684a375]{margin-top:5px;margin-bottom:10px;padding:8px}button[data-v-1684a375]{margin-top:10px;padding:10px}table[data-v-1684a375]{width:100%;border-collapse:collapse;margin-top:20px}td[data-v-1684a375],th[data-v-1684a375]{border:1px solid #ccc;padding:10px;text-align:left}th[data-v-1684a375]{background-color:#f4f4f4}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.dialog-overlay[data-v-21ade8c0]{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);display:flex;justify-content:center;align-items:center}.dialog[data-v-21ade8c0]{background:#fff;padding:20px;border-radius:5px;max-width:400px;width:100%;text-align:center}button[data-v-21ade8c0]{margin-top:20px}.login[data-v-40a158c0]{max-width:400px;margin:auto}form[data-v-40a158c0]{display:flex;flex-direction:column}label[data-v-40a158c0]{margin-top:10px}button[data-v-40a158c0]{margin-top:20px}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
div[data-v-334e7b82]{padding:20px}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.institution-management[data-v-ff992c44]{max-width:600px;margin:auto;padding:20px;border:1px solid #ccc;border-radius:5px}form[data-v-ff992c44]{display:flex;flex-direction:column;margin-bottom:20px}label[data-v-ff992c44]{margin-top:10px}input[data-v-ff992c44]{margin-top:5px;margin-bottom:10px;padding:8px}button[data-v-ff992c44]{margin-top:10px;padding:10px}table[data-v-ff992c44]{width:100%;border-collapse:collapse;margin-top:20px}td[data-v-ff992c44],th[data-v-ff992c44]{border:1px solid #ccc;padding:10px;text-align:left}th[data-v-ff992c44]{background-color:#f4f4f4}

View File

@@ -0,0 +1 @@
div[data-v-68b32234]{padding:20px}ul[data-v-68b32234]{list-style:none;padding:0;margin:0}li[data-v-68b32234]{padding:0;margin:0}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.register[data-v-182f8976]{max-width:400px;margin:auto}form[data-v-182f8976]{display:flex;flex-direction:column}label[data-v-182f8976]{margin-top:10px}button[data-v-182f8976]{margin-top:20px}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.dialog-overlay[data-v-21ade8c0]{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);display:flex;justify-content:center;align-items:center}.dialog[data-v-21ade8c0]{background:#fff;padding:20px;border-radius:5px;max-width:400px;width:100%;text-align:center}button[data-v-21ade8c0]{margin-top:20px}

View File

@@ -0,0 +1 @@
form div[data-v-454efcad]{margin-bottom:10px}.uploaded-image[data-v-454efcad]{display:inline-block;margin:0 0 .5em .5em;border:1px solid #e0e0e0;padding:10px}.uploaded-image input[data-v-454efcad],.uploaded-image textarea[data-v-454efcad]{width:100%;margin:5px 0}

View File

@@ -0,0 +1 @@
.forgot-password[data-v-017249f5]{max-width:400px;margin:auto}form[data-v-017249f5]{display:flex;flex-direction:column}label[data-v-017249f5]{margin-top:10px}button[data-v-017249f5]{margin-top:20px}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.event-places-management[data-v-920e5ffc]{max-width:600px;margin:auto;padding:20px;border:1px solid #ccc;border-radius:5px}form[data-v-920e5ffc]{display:flex;flex-direction:column;margin-bottom:20px}label[data-v-920e5ffc]{margin-top:10px}input[data-v-920e5ffc]{margin-top:5px;margin-bottom:10px;padding:8px}button[data-v-920e5ffc]{margin-top:10px;padding:10px}table[data-v-920e5ffc]{width:100%;border-collapse:collapse;margin-top:20px}td[data-v-920e5ffc],th[data-v-920e5ffc]{border:1px solid #ccc;padding:10px;text-align:left}th[data-v-920e5ffc]{background-color:#f4f4f4}

View File

@@ -0,0 +1 @@
.user-administration[data-v-a495c756]{padding:20px}.user-administration h1[data-v-a495c756],.user-administration h2[data-v-a495c756]{margin-bottom:20px}.user-administration form[data-v-a495c756]{display:flex;flex-direction:column;margin-bottom:20px}.user-administration label[data-v-a495c756]{margin-top:10px}.user-administration input[type=email][data-v-a495c756],.user-administration input[type=password][data-v-a495c756],.user-administration input[type=text][data-v-a495c756]{padding:5px;font-size:16px}.user-administration ul[data-v-a495c756]{list-style-type:none;padding:0}.user-administration li[data-v-a495c756]{padding:10px;border-bottom:1px solid #ddd;cursor:pointer}.user-administration li[data-v-a495c756]:hover{background-color:#f0f0f0}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.impressum[data-v-612786fa]{max-width:800px;margin:auto;padding:20px}h1[data-v-612786fa],h2[data-v-612786fa],h3[data-v-612786fa],h4[data-v-612786fa]{margin-top:20px;color:#333}p[data-v-612786fa]{line-height:1.6}a[data-v-612786fa]{color:#007bff;text-decoration:none}a[data-v-612786fa]:hover{text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
table.worships[data-v-0a1907cc]{border-collapse:collapse;width:100%}table.worships td[data-v-0a1907cc]{border:1px solid #000;text-align:center}h3[data-v-0a1907cc]{margin:0}table.worships td div[data-v-0a1907cc]{margin:5px}.highlight-time[data-v-0a1907cc]{text-decoration:underline}.neighborhood-invitation[data-v-0a1907cc]{font-weight:700;color:#0020e0}.image[data-v-9b711a1e]{max-width:400px;max-height:300px}.event-name[data-v-2da2977b]{font-weight:700}.event-table[data-v-2da2977b]{border-collapse:collapse}.event-table td[data-v-2da2977b]{border:1px solid #000}.homepage[data-v-2da2977b]{border:1px solid #9400ff;padding:.5em;text-align:center}.description[data-v-2da2977b]{padding:.5em 0}.contact-box p[data-v-40357d31]{margin:0}span[data-v-fa4cadae]{cursor:pointer;color:blue;text-decoration:underline}

View File

@@ -0,0 +1 @@
.navbar[data-v-7c384298]{background-color:#9400ff;overflow:visible;height:47px;display:flex;width:100%}.navbar ul[data-v-7c384298]{list-style-type:none;margin:0;padding:0;display:flex}.navbar li[data-v-7c384298]{position:relative}.navbar a[data-v-7c384298],.navbar li>span[data-v-7c384298]{display:block;color:#fff;text-align:center;padding:14px 20px;text-decoration:none;font-weight:700}.navbar a[data-v-7c384298]:hover{background-color:#7a00d1}.menu-icon[data-v-7c384298]{width:20px;height:20px;margin-right:5px}.dropdown-content[data-v-7c384298]{position:absolute;background-color:#9400ff;min-width:200px;z-index:1;top:100%;left:0;opacity:0;visibility:hidden;transition:opacity .2s ease-in-out,visibility .2s ease-in-out;box-shadow:2px 2px 4px #666}.dropdown-content a[data-v-7c384298]{color:#fff;padding:12px 16px;text-decoration:none;display:block;text-align:left}.dropdown-content a[data-v-7c384298]:hover{background-color:#7a00d1}.navbar li:hover .dropdown-content[data-v-7c384298]{opacity:1;visibility:visible}.fade-enter-active[data-v-7c384298],.fade-leave-active[data-v-7c384298]{transition:opacity .2s ease-in-out,visibility .2s ease-in-out}.fade-enter[data-v-7c384298],.fade-leave-to[data-v-7c384298]{opacity:0;visibility:hidden}header[data-v-0a99f72e]{display:flex;flex-direction:column;width:100%;background-color:#e0bfff}.header-title[data-v-0a99f72e]{display:flex;align-items:center;justify-content:space-between;width:100%;padding:.3em .5em}header h1[data-v-0a99f72e]{margin:0}.reload-icon[data-v-0a99f72e]{font-size:16px;cursor:pointer;margin-left:10px;background-color:#e0bfff;color:#fff;padding:5px;border-radius:50%}.reload-icon[data-v-0a99f72e]:hover{color:#7a00d1}.footer[data-v-cc31ea16]{background-color:#0b1735;bottom:0;left:0;width:100%;padding:7px;justify-content:space-between}.footer[data-v-cc31ea16],.left-links[data-v-cc31ea16],.right-links[data-v-cc31ea16]{display:flex;align-items:center}.footer a[data-v-cc31ea16]{color:#fff;padding-right:20px;text-decoration:none}.footer a.login-link[data-v-cc31ea16]{color:#444}.footer a.logout-link[data-v-cc31ea16]{cursor:pointer}body,html{height:100%;margin:0;padding:0;background-color:#fff;font-family:Arial,sans-serif;width:100%;overflow:hidden}#app{display:flex;flex-direction:column;height:100%}.content-section{flex:1;display:flex;color:#000;overflow:hidden}.left-column,.right-column{flex:1;overflow-y:auto}.left-column{margin:.5em 0 .5em .5em;padding-right:.5em;background-color:#fff}.right-column{background-color:#d9e2f3}.right-column h2{text-align:center;color:#000}.right-column img{display:block;margin:0 auto;max-width:100%;height:auto}@media (max-width:768px){.content-section{flex-direction:column}.left-column,.right-column{padding:10px}}.htmleditor{background-color:#fff;width:calc(100% - 26px);height:31em;border:1px solid #000;margin:7px;padding:5px;overflow:auto}.htmleditor table{border:1px solid #e0e0e0;border-collapse:collapse}.htmleditor td,.htmleditor th{border:1px solid #e0e0e0}

BIN
public/images/homepage2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

1
public/index.html Normal file
View File

@@ -0,0 +1 @@
<!doctype html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>miriamgemeinde</title><script defer="defer" src="/js/chunk-vendors.a58901d9.js"></script><script defer="defer" src="/js/app.2b3ac443.js"></script><link href="/css/app.c2c4030a.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors.a58901d9.js"></script><script defer="defer" src="/js/app.62331f73.js"></script><link href="/css/app.c2c4030a.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors.a58901d9.js"></script><script defer="defer" src="/js/app.f7f58406.js"></script><link href="/css/app.c2c4030a.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but miriamgemeinde doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

View File

@@ -1,6 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -1,10 +1,9 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getAllPages, uploadImage, uploadImages, saveImageDetails, saveImageDetailsBulk, getImages, getImagesByPage, getImageById, getImageByHash, updateImage } = require('../controllers/imageController'); const { getAllPages, uploadImage, saveImageDetails, getImages, getImagesByPage, getImageById, getImageByHash, updateImage } = require('../controllers/imageController');
const authMiddleware = require('../middleware/authMiddleware') const authMiddleware = require('../middleware/authMiddleware')
router.post('/', authMiddleware, uploadImage, saveImageDetails); router.post('/', authMiddleware, uploadImage, saveImageDetails);
router.post('/bulk', authMiddleware, uploadImages, saveImageDetailsBulk);
router.get('/', authMiddleware, getImages); router.get('/', authMiddleware, getImages);
router.get('/page/:pageId', getImagesByPage); router.get('/page/:pageId', getImagesByPage);
router.put('/hash/:id', getImageByHash); router.put('/hash/:id', getImageByHash);

View File

@@ -1,19 +0,0 @@
const express = require('express');
const router = express.Router();
const {
getAllLiturgicalDays,
getLiturgicalDayNames,
loadLiturgicalYear,
createLiturgicalDay,
deleteLiturgicalDay
} = require('../controllers/liturgicalDayController');
const authMiddleware = require('../middleware/authMiddleware');
router.get('/', getAllLiturgicalDays);
router.get('/names', getLiturgicalDayNames);
router.post('/load-year', authMiddleware, loadLiturgicalYear);
router.post('/', authMiddleware, createLiturgicalDay);
router.delete('/:id', authMiddleware, deleteLiturgicalDay);
module.exports = router;

View File

@@ -1,12 +1,13 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getAllUsers, createUser, updateUser, deleteUser, getUserById } = require('../controllers/userController'); const { getAllUsers, createUser, updateUser, deleteUser, getUserById, changePassword } = require('../controllers/userController');
const authMiddleware = require('../middleware/authMiddleware'); const authMiddleware = require('../middleware/authMiddleware');
router.get('/', authMiddleware, getAllUsers); router.get('/', authMiddleware, getAllUsers);
router.get('/:id', authMiddleware, getUserById); router.get('/:id', authMiddleware, getUserById);
router.post('/', authMiddleware, createUser); router.post('/', authMiddleware, createUser);
router.put('/:id', authMiddleware, updateUser); router.put('/:id', authMiddleware, updateUser);
router.put('/:id/change-password', authMiddleware, changePassword);
router.delete('/:id', authMiddleware, deleteUser); router.delete('/:id', authMiddleware, deleteUser);
module.exports = router; module.exports = router;

View File

@@ -1,17 +0,0 @@
const express = require('express');
const router = express.Router();
const authMiddleware = require('../middleware/authMiddleware');
const {
getAllWorshipLeaders,
createWorshipLeader,
updateWorshipLeader,
deleteWorshipLeader,
} = require('../controllers/worshipLeaderController');
router.get('/', authMiddleware, getAllWorshipLeaders);
router.post('/', authMiddleware, createWorshipLeader);
router.put('/:id', authMiddleware, updateWorshipLeader);
router.delete('/:id', authMiddleware, deleteWorshipLeader);
module.exports = router;

View File

@@ -1,19 +1,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships, getWorshipOptions, importWorships, importWorshipsNbrCsv, importWorshipsNbrPlanning, uploadImportFile, exportWorships, saveImportedWorships, importNewsletterPdf } = require('../controllers/worshipController'); const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships } = require('../controllers/worshipController');
const authMiddleware = require('../middleware/authMiddleware'); const authMiddleware = require('../middleware/authMiddleware');
router.get('/', getAllWorships); router.get('/', getAllWorships);
router.get('/options', getWorshipOptions);
router.post('/', authMiddleware, createWorship); router.post('/', authMiddleware, createWorship);
router.post('/import', authMiddleware, uploadImportFile, importWorships);
router.post('/import/nbr-planning', authMiddleware, uploadImportFile, importWorshipsNbrPlanning);
router.post('/import/nbr-csv', authMiddleware, uploadImportFile, importWorshipsNbrCsv);
router.post('/import/newsletter-pdf', authMiddleware, uploadImportFile, importNewsletterPdf);
router.post('/import/save', authMiddleware, saveImportedWorships);
router.put('/:id', authMiddleware, updateWorship); router.put('/:id', authMiddleware, updateWorship);
router.delete('/:id', authMiddleware, deleteWorship); router.delete('/:id', authMiddleware, deleteWorship);
router.get('/filtered', getFilteredWorships); router.get('/filtered', getFilteredWorships);
router.get('/export', authMiddleware, exportWorships);
module.exports = router; module.exports = router;

View File

@@ -1,13 +1,8 @@
const express = require('express'); const express = require('express');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const cors = require('cors'); const cors = require('cors');
const http = require('http'); const https = require('https');
require('dotenv').config(); const fs = require('fs');
// Erhöhe maxHttpHeaderSize für Node.js (Standard ist 8KB, erhöhe auf 16KB)
if (http.maxHeaderSize !== undefined) {
http.maxHeaderSize = 16384;
}
const sequelize = require('./config/database'); const sequelize = require('./config/database');
const authRouter = require('./routes/auth'); const authRouter = require('./routes/auth');
const eventTypesRouter = require('./routes/eventtypes'); const eventTypesRouter = require('./routes/eventtypes');
@@ -18,85 +13,16 @@ const institutionRouter = require('./routes/institutions');
const eventRouter = require('./routes/event'); const eventRouter = require('./routes/event');
const menuDataRouter = require('./routes/menuData'); const menuDataRouter = require('./routes/menuData');
const worshipRouter = require('./routes/worships'); const worshipRouter = require('./routes/worships');
const worshipLeadersRouter = require('./routes/worshipLeaders');
const pageRouter = require('./routes/pages'); const pageRouter = require('./routes/pages');
const userRouter = require('./routes/users'); const userRouter = require('./routes/users');
const imageRouter = require('./routes/image'); const imageRouter = require('./routes/image');
const filesRouter = require('./routes/files'); const filesRouter = require('./routes/files');
const liturgicalDaysRouter = require('./routes/liturgicalDays');
const app = express(); const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || process.env.PORT, 10) || 3000; const PORT = 3002;
// CORS mit Whitelist und tolerantem Fallback für fehlende Origin-Header app.use(cors());
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '') app.use(bodyParser.json());
.split(',')
.map(s => s.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
if (!origin) {
return callback(null, true); // z.B. Healthchecks/curl/Server-zu-Server
}
if (allowedOrigins.length === 0) {
return callback(null, true); // Fallback: alles erlauben
}
// Prüfe exakte Übereinstimmung
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Für Entwicklung: Erlaube localhost und torstens auf jedem Port
try {
const originUrl = new URL(origin);
const hostname = originUrl.hostname.toLowerCase();
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
const isTorstens = hostname === 'torstens' || hostname.includes('torstens');
if (isLocalhost || isTorstens) {
return callback(null, true);
}
} catch (e) {
// Falls URL-Parsing fehlschlägt, prüfe mit Regex
const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|::1)(:\d+)?$/.test(origin);
const isTorstens = /^https?:\/\/torstens(:\d+)?/.test(origin);
if (isLocalhost || isTorstens) {
return callback(null, true);
}
}
// Prüfe auch ohne Port (für Flexibilität)
const originWithoutPort = origin.replace(/:\d+$/, '');
const allowedWithoutPort = allowedOrigins.some(allowed => {
const allowedWithoutPort = allowed.replace(/:\d+$/, '');
return originWithoutPort === allowedWithoutPort;
});
if (allowedWithoutPort) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'), false);
},
credentials: true,
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
allowedHeaders: ['Content-Type','Authorization']
}));
// Express 5 nutzt path-to-regexp v6; '*' ist dort als Pattern ungueltig.
app.options('/{*any}', cors());
// Erhöhe Header-Limits für große Requests
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
// Erhöhe maxHttpHeaderSize (Node.js 18.3.0+)
if (process.versions.node.split('.')[0] >= 18) {
require('http').maxHeaderSize = 16384; // 16KB (Standard ist 8KB)
}
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.use('/api/event-types', eventTypesRouter); app.use('/api/event-types', eventTypesRouter);
@@ -107,15 +33,22 @@ app.use('/api/institutions', institutionRouter);
app.use('/api/events', eventRouter); app.use('/api/events', eventRouter);
app.use('/api/menu-data', menuDataRouter); app.use('/api/menu-data', menuDataRouter);
app.use('/api/worships', worshipRouter); app.use('/api/worships', worshipRouter);
app.use('/api/worship-leaders', worshipLeadersRouter);
app.use('/api/page-content', pageRouter); app.use('/api/page-content', pageRouter);
app.use('/api/users', userRouter); app.use('/api/users', userRouter);
app.use('/api/image', imageRouter); app.use('/api/image', imageRouter);
app.use('/api/files', filesRouter); app.use('/api/files', filesRouter);
app.use('/api/liturgical-days', liturgicalDaysRouter);
const options = {
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.cert'),
};
sequelize.sync().then(() => { sequelize.sync().then(() => {
app.listen(PORT, '0.0.0.0', () => { /* https.createServer(options, app).listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT} (IPv4 und IPv6)`); console.log(`Server läuft auf Port ${PORT}`);
});*/
app.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
}); });
}); });

186
services/AuthService.js Normal file
View File

@@ -0,0 +1,186 @@
const bcrypt = require('bcryptjs');
const { User, PasswordResetToken } = require('../models');
const jwt = require('jsonwebtoken');
const { addTokenToBlacklist } = require('../utils/blacklist');
const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
const crypto = require('crypto');
class AuthService {
/**
* User registrieren
*/
async register(userData) {
const { name, email, password } = userData;
if (!name || !email || !password) {
throw new Error('VALIDATION_ERROR: Alle Felder sind erforderlich');
}
// Prüfen ob E-Mail bereits existiert
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
throw new Error('EMAIL_ALREADY_EXISTS');
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({
name,
email,
password: hashedPassword,
active: true
});
return this.getSafeUserData(user);
}
/**
* User einloggen
*/
async login(credentials) {
const { email, password } = credentials;
if (!email || !password) {
throw new Error('VALIDATION_ERROR: Email und Passwort sind erforderlich');
}
const user = await User.findOne({ where: { email } });
if (!user) {
throw new Error('INVALID_CREDENTIALS');
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
throw new Error('INVALID_CREDENTIALS');
}
if (!user.active) {
throw new Error('ACCOUNT_INACTIVE');
}
const token = jwt.sign(
{ id: user.id, name: user.name, email: user.email },
'zTxVgptmPl9!_dr%xxx9999(dd)',
{ expiresIn: '1h' }
);
return {
message: 'Login erfolgreich',
token,
user: this.getSafeUserData(user)
};
}
/**
* User ausloggen
*/
async logout(token) {
if (!token) {
throw new Error('VALIDATION_ERROR: Kein Token bereitgestellt');
}
addTokenToBlacklist(token);
return { message: 'Logout erfolgreich' };
}
/**
* Passwort vergessen - E-Mail senden
*/
async forgotPassword(email) {
if (!email) {
throw new Error('VALIDATION_ERROR: E-Mail-Adresse ist erforderlich');
}
const user = await User.findOne({ where: { email } });
if (!user) {
// Aus Sicherheitsgründen immer Erfolg melden
return { message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' };
}
// Alte Reset-Tokens für diesen User löschen
await PasswordResetToken.destroy({ where: { userId: user.id } });
// Neuen Reset-Token generieren
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde
await PasswordResetToken.create({
userId: user.id,
token,
expiresAt
});
// Reset-URL generieren
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`;
// E-Mail versenden
const emailTemplate = getPasswordResetEmailTemplate(resetUrl, user.name);
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@miriamgemeinde.de',
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text
});
console.log('Password reset email sent to:', email);
return { message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' };
}
/**
* Passwort zurücksetzen
*/
async resetPassword(token, newPassword) {
if (!token || !newPassword) {
throw new Error('VALIDATION_ERROR: Token und neues Passwort sind erforderlich');
}
if (newPassword.length < 6) {
throw new Error('VALIDATION_ERROR: Passwort muss mindestens 6 Zeichen lang sein');
}
// Token validieren
const resetToken = await PasswordResetToken.findOne({
where: {
token,
used: false,
expiresAt: {
[require('sequelize').Op.gt]: new Date()
}
},
include: [{ model: User, as: 'user' }]
});
if (!resetToken) {
throw new Error('INVALID_RESET_TOKEN');
}
// Passwort hashen und aktualisieren
const hashedPassword = await bcrypt.hash(newPassword, 10);
await User.update(
{ password: hashedPassword },
{ where: { id: resetToken.userId } }
);
// Token als verwendet markieren
await resetToken.update({ used: true });
console.log('Password reset successful for user:', resetToken.userId);
return { message: 'Passwort erfolgreich zurückgesetzt' };
}
/**
* Sichere User-Daten extrahieren (ohne Passwort)
*/
getSafeUserData(user) {
return {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at
};
}
}
module.exports = new AuthService();

276
services/EventService.js Normal file
View File

@@ -0,0 +1,276 @@
const { Event, Institution, EventPlace, ContactPerson, EventType } = require('../models');
const { Op } = require('sequelize');
const moment = require('moment');
class EventService {
/**
* Alle Events abrufen
*/
async getAllEvents() {
try {
const events = await Event.findAll({
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: ['name', 'date', 'time']
});
return events;
} catch (error) {
console.error('Error fetching all events:', error);
throw new Error('EVENTS_FETCH_ERROR');
}
}
/**
* Event anhand ID abrufen
*/
async getEventById(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const event = await Event.findByPk(id, {
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
]
});
if (!event) {
throw new Error('EVENT_NOT_FOUND');
}
return event;
} catch (error) {
console.error('Error fetching event by ID:', error);
throw new Error('EVENT_FETCH_ERROR');
}
}
/**
* Events filtern
*/
async filterEvents(filterData) {
try {
const { id, places, types, display } = filterData;
// Basis-Where-Klausel für zukünftige Events
const where = {
[Op.or]: [
{
date: {
[Op.or]: [
{ [Op.gte]: moment().startOf('day').toDate() },
{ [Op.eq]: null }
]
}
},
{ dayOfWeek: { [Op.gte]: 0 } }
]
};
const order = [
['date', 'ASC'],
['time', 'ASC']
];
// Spezielle Filter
if (id === 'all') {
return await this._getAllFutureEvents(where, order);
}
if (id === 'home') {
return await this._getHomepageEvents(where, order);
}
if (!id && !places && !types) {
return { events: [], places: [], types: [], contactPersons: [] };
}
// Weitere Filter anwenden
if (id) {
where.id = id;
}
if (places && places.length > 0) {
where.event_place_id = {
[Op.in]: places.map(id => parseInt(id))
};
}
if (types && types.length > 0) {
where.eventTypeId = {
[Op.in]: types.map(id => parseInt(id))
};
}
const events = await Event.findAll({
where,
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
});
// Events basierend auf Display-Feldern filtern
const displayFields = display || [];
const filteredEvents = this._filterEventFields(events, displayFields);
return { events: filteredEvents };
} catch (error) {
console.error('Error filtering events:', error);
throw new Error('EVENT_FILTER_ERROR');
}
}
/**
* Event erstellen
*/
async createEvent(eventData) {
try {
const { contactPersonIds, ...eventDataWithoutContacts } = eventData;
// Validierung
if (!eventDataWithoutContacts.name) {
throw new Error('VALIDATION_ERROR: Event-Name ist erforderlich');
}
eventDataWithoutContacts.alsoOnHomepage = eventDataWithoutContacts.alsoOnHomepage ?? 0;
const event = await Event.create(eventDataWithoutContacts);
if (contactPersonIds && contactPersonIds.length > 0) {
await event.setContactPersons(contactPersonIds);
}
return event;
} catch (error) {
console.error('Error creating event:', error);
throw new Error('EVENT_CREATE_ERROR');
}
}
/**
* Event aktualisieren
*/
async updateEvent(id, eventData) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const { contactPersonIds, ...eventDataWithoutContacts } = eventData;
const event = await Event.findByPk(id);
if (!event) {
throw new Error('EVENT_NOT_FOUND');
}
await event.update(eventDataWithoutContacts);
if (contactPersonIds !== undefined) {
await event.setContactPersons(contactPersonIds || []);
}
return event;
} catch (error) {
console.error('Error updating event:', error);
throw new Error('EVENT_UPDATE_ERROR');
}
}
/**
* Event löschen
*/
async deleteEvent(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const event = await Event.findByPk(id);
if (!event) {
throw new Error('EVENT_NOT_FOUND');
}
await event.destroy();
return { message: 'Event erfolgreich gelöscht' };
} catch (error) {
console.error('Error deleting event:', error);
throw new Error('EVENT_DELETE_ERROR');
}
}
/**
* Alle zukünftigen Events abrufen
*/
async _getAllFutureEvents(where, order) {
const events = await Event.findAll({
where,
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
logging: console.log
});
return { events };
}
/**
* Homepage Events abrufen
*/
async _getHomepageEvents(where, order) {
const events = await Event.findAll({
where: {
alsoOnHomepage: 1,
date: { [Op.gte]: moment().startOf('day').toDate() }
},
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } },
],
order: order,
});
return { events };
}
/**
* Event-Felder basierend auf Display-Feldern filtern
*/
_filterEventFields(events, displayFields) {
return events.map(event => {
const filteredEvent = { ...event.toJSON() };
if (!displayFields.includes('name')) delete filteredEvent.name;
if (!displayFields.includes('type')) delete filteredEvent.eventType;
if (!displayFields.includes('place')) delete filteredEvent.eventPlace;
if (!displayFields.includes('description')) delete filteredEvent.description;
if (!displayFields.includes('time')) delete filteredEvent.time;
if (!displayFields.includes('time')) delete filteredEvent.endTime;
if (!displayFields.includes('contactPerson')) delete filteredEvent.contactPersons;
if (!displayFields.includes('day')) delete filteredEvent.dayOfWeek;
if (!displayFields.includes('institution')) delete filteredEvent.institution;
return filteredEvent;
});
}
}
module.exports = new EventService();

101
services/MenuDataService.js Normal file
View File

@@ -0,0 +1,101 @@
const { MenuItem } = require('../models');
class MenuDataService {
/**
* Alle Menü-Daten abrufen
*/
async getMenuData() {
try {
const menuItems = await MenuItem.findAll({
order: [['order_id', 'ASC']],
include: [{
model: MenuItem,
as: 'submenu',
required: false,
order: [['order_id', 'ASC']]
}]
});
const menuData = this.buildMenuStructure(menuItems);
return menuData;
} catch (error) {
console.error('Error fetching menu data:', error);
throw new Error('MENU_DATA_FETCH_ERROR');
}
}
/**
* Menü-Daten speichern
*/
async saveMenuData(menuData) {
try {
if (!Array.isArray(menuData)) {
throw new Error('VALIDATION_ERROR: Menü-Daten müssen ein Array sein');
}
// Menü-Daten anpassen
const adjustedMenuData = menuData.map(item => {
item.parent_id = item.parent_id < 0 ? null : item.parent_id;
return item;
}).sort((a, b) => (a.parent_id === null ? -1 : 1) - (b.parent_id === null ? -1 : 1));
// Alle bestehenden Menü-Items löschen
await MenuItem.destroy({ where: {} });
// Neue Menü-Items erstellen
for (const item of adjustedMenuData) {
await MenuItem.create(item);
}
return { message: 'Menü-Daten erfolgreich gespeichert' };
} catch (error) {
console.error('Error saving menu data:', error);
throw new Error('MENU_DATA_SAVE_ERROR');
}
}
/**
* Menü-Struktur aufbauen
*/
buildMenuStructure(menuItems) {
const menu = [];
const itemMap = {};
// Alle Items in Map speichern
menuItems.forEach(item => {
itemMap[item.id] = {
id: item.id,
name: item.name,
link: item.link,
component: item.component,
showInMenu: item.show_in_menu,
requiresAuth: item.requires_auth,
order_id: item.order_id,
pageTitle: item.page_title,
image: item.image,
submenu: []
};
});
// Hierarchie aufbauen
menuItems.forEach(item => {
if (item.parent_id) {
if (itemMap[item.parent_id]) {
itemMap[item.parent_id].submenu.push(itemMap[item.id]);
}
} else {
menu.push(itemMap[item.id]);
}
});
// Sortierung anwenden
menu.sort((a, b) => a.order_id - b.order_id);
menu.forEach(item => {
item.submenu.sort((a, b) => a.order_id - b.order_id);
});
return menu;
}
}
module.exports = new MenuDataService();

132
services/PageService.js Normal file
View File

@@ -0,0 +1,132 @@
const { Page } = require('../models');
class PageService {
/**
* Seiteninhalt anhand Link abrufen
*/
async getPageContent(link) {
try {
if (!link) {
throw new Error('VALIDATION_ERROR: Link ist erforderlich');
}
const page = await Page.findOne({ where: { link } });
if (!page) {
throw new Error('PAGE_NOT_FOUND');
}
return {
content: page.content || '',
title: page.title || '',
link: page.link
};
} catch (error) {
console.error('Error fetching page content:', error);
throw new Error('PAGE_CONTENT_FETCH_ERROR');
}
}
/**
* Seiteninhalt speichern
*/
async savePageContent(pageData) {
try {
const { link, name, content } = pageData;
if (!link || !name) {
throw new Error('VALIDATION_ERROR: Link und Name sind erforderlich');
}
// Prüfen ob Seite bereits existiert
const existingPage = await Page.findOne({ where: { link } });
if (existingPage) {
// Seite aktualisieren
await existingPage.update({
content: content || '',
title: name,
updated_at: new Date()
});
} else {
// Neue Seite erstellen
await Page.create({
link,
title: name,
content: content || '',
created_at: new Date(),
updated_at: new Date()
});
}
return { message: 'Seiteninhalt erfolgreich gespeichert' };
} catch (error) {
console.error('Error saving page content:', error);
throw new Error('PAGE_CONTENT_SAVE_ERROR');
}
}
/**
* Alle Seiten abrufen
*/
async getAllPages() {
try {
const pages = await Page.findAll({
order: [['title', 'ASC']],
attributes: ['id', 'link', 'title', 'created_at', 'updated_at']
});
return pages;
} catch (error) {
console.error('Error fetching all pages:', error);
throw new Error('PAGES_FETCH_ERROR');
}
}
/**
* Seite anhand ID abrufen
*/
async getPageById(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const page = await Page.findByPk(id);
if (!page) {
throw new Error('PAGE_NOT_FOUND');
}
return page;
} catch (error) {
console.error('Error fetching page by ID:', error);
throw new Error('PAGE_FETCH_ERROR');
}
}
/**
* Seite löschen
*/
async deletePage(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const page = await Page.findByPk(id);
if (!page) {
throw new Error('PAGE_NOT_FOUND');
}
await page.destroy();
return { message: 'Seite erfolgreich gelöscht' };
} catch (error) {
console.error('Error deleting page:', error);
throw new Error('PAGE_DELETE_ERROR');
}
}
}
module.exports = new PageService();

140
services/UserService.js Normal file
View File

@@ -0,0 +1,140 @@
const { User } = require('../models');
const bcrypt = require('bcryptjs');
class UserService {
/**
* Alle User abrufen (ohne sensible Daten)
*/
async getAllUsers() {
const users = await User.findAll({
order: [['name', 'ASC']],
attributes: ['id', 'name', 'email', 'active', 'created_at', 'updated_at']
});
return users;
}
/**
* User anhand ID abrufen (ohne sensible Daten)
*/
async getUserById(id) {
const user = await User.findByPk(id, {
attributes: ['id', 'name', 'email', 'active', 'created_at', 'updated_at']
});
if (!user) {
throw new Error('USER_NOT_FOUND');
}
return user;
}
/**
* Neuen User erstellen
*/
async createUser(userData) {
// Passwort hashen falls vorhanden
if (userData.password) {
userData.password = await bcrypt.hash(userData.password, 10);
}
const user = await User.create(userData);
// Sichere User-Daten zurückgeben
return this.getSafeUserData(user);
}
/**
* User aktualisieren (ohne sensible Felder)
*/
async updateUser(id, updateData) {
const user = await User.findByPk(id);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
// Erstelle sichere Update-Daten
const safeUpdateData = this.getSafeUpdateData(updateData);
await user.update(safeUpdateData);
return this.getSafeUserData(user);
}
/**
* User löschen
*/
async deleteUser(id) {
const user = await User.findByPk(id);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
await user.destroy();
return true;
}
/**
* User anhand E-Mail abrufen (für interne Verwendung)
*/
async getUserByEmail(email) {
return await User.findOne({ where: { email } });
}
/**
* Passwort ändern (separate Methode für sichere Passwort-Änderung)
*/
async changePassword(id, currentPassword, newPassword) {
const user = await User.findByPk(id);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
// Aktuelles Passwort prüfen
const isValidPassword = await bcrypt.compare(currentPassword, user.password);
if (!isValidPassword) {
throw new Error('INVALID_CURRENT_PASSWORD');
}
// Neues Passwort hashen und speichern
const hashedPassword = await bcrypt.hash(newPassword, 10);
await user.update({ password: hashedPassword });
return true;
}
/**
* Sichere User-Daten extrahieren (ohne Passwort)
*/
getSafeUserData(user) {
return {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at,
updated_at: user.updated_at
};
}
/**
* Sichere Update-Daten erstellen (ohne sensible Felder)
*/
getSafeUpdateData(updateData) {
const safeData = { ...updateData };
// Entferne sensible Felder
delete safeData.password;
delete safeData.id;
delete safeData.created_at;
// Setze updated_at
safeData.updated_at = new Date();
return safeData;
}
}
module.exports = new UserService();

View File

@@ -1,4 +0,0 @@
-- Ablaufdatum zu Kontaktpersonen hinzufügen
ALTER TABLE `contact_persons`
ADD COLUMN `expiryDate` DATE NULL AFTER `email`;

View File

@@ -1,12 +0,0 @@
-- Tabelle für liturgische Kalendertage erstellen
CREATE TABLE IF NOT EXISTS `liturgical_days` (
`id` INT NOT NULL AUTO_INCREMENT,
`date` DATE NOT NULL,
`dayName` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Index für schnellere Suche nach dayName
CREATE INDEX `idx_dayName` ON `liturgical_days` (`dayName`);

View File

@@ -62,36 +62,49 @@ export default {
<style> <style>
/* eslint-disable */ /* eslint-disable */
/* Layout: globale Typografie/Farben in design-tokens.css */ html,
body {
height: 100%;
margin: 0;
padding: 0;
background-color: #ffffff;
font-family: Arial, sans-serif;
width: 100%;
overflow-x: hidden; /* Prevent horizontal scrolling */
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
.content-section { .content-section {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
color: var(--color-text); color: #000;
overflow-y: hidden; overflow-y: hidden;
} }
.left-column { .left-column {
flex: 1; flex: 1;
min-width: 0; min-width: 1000px;
max-width: 100%; margin: 0.5em 0 0.5em 0.5em;
margin: var(--space-2) 0 var(--space-2) var(--space-2); padding-right: 0.5em;
padding-right: var(--space-2); background-color: #ffffff;
background-color: var(--color-bg-page);
overflow-y: auto; overflow-y: auto;
} }
.right-column { .right-column {
flex: 1; flex: 1;
min-width: 0; background-color: #d9e2f3;
background-color: var(--color-bg-sidebar);
overflow-y: auto; overflow-y: auto;
margin: 0 var(--space-2) var(--space-2) 0; margin: 0 7px 7px 0;
} }
.right-column h2 { .right-column h2 {
text-align: center; text-align: center;
color: var(--color-text); color: #000;
} }
.right-column img { .right-column img {
@@ -104,8 +117,8 @@ export default {
.right-column-overlay { .right-column-overlay {
max-height: 150px; max-height: 150px;
overflow-y: hidden; overflow-y: hidden;
margin-top: var(--space-3); margin-top: 10px;
background-color: var(--color-bg-sidebar); background-color: #d9e2f3;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@@ -123,9 +136,7 @@ export default {
} }
.left-column { .left-column {
padding: var(--space-3); padding: 10px;
margin-left: 0;
margin-right: 0;
} }
.right-column { .right-column {
@@ -135,7 +146,7 @@ export default {
.right-column-overlay { .right-column-overlay {
display: flex; display: flex;
max-height: 150px; max-height: 150px;
background-color: var(--color-bg-page); background-color: #ffffff;
} }
.right-column-overlay img { .right-column-overlay img {
@@ -152,7 +163,7 @@ export default {
.left-column, .left-column,
.right-column { .right-column {
padding: var(--space-3); padding: 10px;
} }
.right-column { .right-column {

View File

@@ -1,93 +0,0 @@
/**
* Phase 3 Inhaltsmodule als Karten (ruhig, seriös, wiederverwendbar).
*/
.mg-stack {
display: grid;
gap: var(--space-4);
}
.mg-card {
background: var(--color-bg-page);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
padding: var(--space-4);
}
.mg-card:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
}
.mg-card--highlight {
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 1px rgba(148, 0, 255, 0.18);
}
.mg-card__grid {
display: grid;
grid-template-columns: 160px 1fr;
gap: var(--space-4);
align-items: start;
}
@media (max-width: 640px) {
.mg-card__grid {
grid-template-columns: 1fr;
}
}
.mg-media {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 6px;
overflow: hidden;
background: var(--color-bg-subtle);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.mg-media > img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.mg-title {
margin: 0 0 var(--space-2) 0;
font-weight: 600;
}
.mg-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
margin: 0 0 var(--space-2) 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.mg-meta strong {
color: var(--color-text);
font-weight: 600;
}
.mg-text {
margin: 0;
}
.mg-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(0, 0, 0, 0.1);
color: var(--color-text);
font-size: 0.85rem;
}
.mg-accent-left {
border-left: 6px solid rgba(0, 0, 0, 0.12);
padding-left: var(--space-3);
}

View File

@@ -1,125 +0,0 @@
/**
* Design-Tokens Phase 1 Farben (EKHN-Violett unverändert), Abstände, Typografie.
* Globale Basistyles für html/body/#app.
*/
:root {
/* Marke EKHN Primärfarben fix */
--color-brand-primary: #9400ff;
--color-brand-primary-hover: #7a00d1;
--color-brand-tint: #e0bfff;
/* Neutrale Flächen & Text */
--color-bg-page: #ffffff;
--color-bg-subtle: #f8f9fb;
--color-bg-sidebar: #d9e2f3;
--color-text: #1a1a1a;
--color-text-muted: #4a4a4a;
/* Footer (dunkler Grund) */
--color-footer-bg: #0b1735;
--color-footer-link: #e8edf5;
--color-footer-link-hover: #ffffff;
/* Abstände (4px-Raster) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Typografie */
--font-family-base: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
--font-size-body: 1rem;
--line-height-body: 1.5;
--font-size-h1: clamp(1.25rem, 2.5vw, 1.75rem);
--font-size-h2: clamp(1.15rem, 2vw, 1.35rem);
--font-size-h3: clamp(1.05rem, 1.5vw, 1.2rem);
--shadow-dropdown: 2px 2px 6px rgba(0, 0, 0, 0.22);
--transition-fast: 140ms ease;
--transition-standard: 220ms ease;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
width: 100%;
overflow-x: hidden;
}
body {
background-color: var(--color-bg-page);
color: var(--color-text);
font-family: var(--font-family-base);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
#app {
display: flex;
flex-direction: column;
height: 100%;
}
/* Sanfte Standard-Hierarchie; Komponenten können gezielt überschreiben */
:where(h1) {
font-size: var(--font-size-h1);
font-weight: 600;
line-height: 1.25;
}
:where(h2) {
font-size: var(--font-size-h2);
font-weight: 600;
line-height: 1.3;
}
:where(h3) {
font-size: var(--font-size-h3);
font-weight: 600;
line-height: 1.35;
}
:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
a,
button,
[role="button"] {
transition:
color var(--transition-fast),
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-standard),
transform var(--transition-fast);
}
/* Schutz vor List-Styles in vue-multiselect-Dropdowns */
.multiselect__content-wrapper ul,
.multiselect__content {
list-style: none;
margin: 0;
padding: 0;
}
.multiselect__content li,
.multiselect__element {
margin: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -1,51 +1,15 @@
import axios from 'axios'; import axios from 'axios';
import store from './store';
import router from './router';
// Einheitliche Basis-URL: axios.defaults.baseURL = process.env.VUE_APP_BACKEND_URL;
// - immer relativ zur aktuellen Origin console.log(process.env.VUE_APP_BACKEND_URL);
// - kein absoluter http/https-Host → verhindert Mixed-Content-Probleme
axios.defaults.baseURL = '/api';
console.log('Axios baseURL:', axios.defaults.baseURL);
function clearStoredLogin() {
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('user');
localStorage.removeItem('token');
delete axios.defaults.headers.common.Authorization;
}
function getTokenPayload(token) {
try {
const payload = token.split('.')[1];
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + (4 - normalized.length % 4) % 4, '=');
return JSON.parse(atob(padded));
} catch (error) {
return null;
}
}
function isTokenUsable(token) {
if (!token || token === 'undefined' || token === 'null') {
return false;
}
const payload = getTokenPayload(token);
if (!payload || !payload.exp) {
return true;
}
return payload.exp * 1000 > Date.now();
}
axios.interceptors.request.use( axios.interceptors.request.use(
config => { config => {
const token = localStorage.getItem('token'); const token = store.state.token;
config.headers = config.headers || {};
if (token) { if (token) {
if (isTokenUsable(token)) { config.headers.Authorization = `Bearer ${token}`;
config.headers.Authorization = `Bearer ${token}`;
} else {
clearStoredLogin();
delete config.headers.Authorization;
}
} }
return config; return config;
}, },
@@ -59,23 +23,9 @@ axios.interceptors.response.use(
return response; return response;
}, },
error => { error => {
const requestUrl = error.config?.url || ''; if (error.response && error.response.status === 401) {
const isLoginRequest = requestUrl.includes('/auth/login'); store.dispatch('logout');
const isLogoutRequest = requestUrl.includes('/auth/logout'); router.push('/auth/login');
const hasStoredToken = isTokenUsable(localStorage.getItem('token'));
const hadAuthHeader = !!error.config?.headers?.Authorization;
if (
error.response &&
error.response.status === 401 &&
!isLoginRequest &&
!isLogoutRequest &&
(hasStoredToken || hadAuthHeader)
) {
clearStoredLogin();
if (window.location.pathname !== '/auth/login') {
window.location.replace('/auth/login');
}
} }
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -1,90 +0,0 @@
<template>
<nav v-if="crumbs.length" class="breadcrumbs" aria-label="Brotkrumen">
<ol>
<li v-for="(c, idx) in crumbs" :key="c.to + '-' + idx">
<router-link v-if="idx < crumbs.length - 1" :to="c.to">{{ c.label }}</router-link>
<span v-else aria-current="page">{{ c.label }}</span>
</li>
</ol>
</nav>
</template>
<script>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
function findLabel(menuItems, link) {
for (const item of menuItems) {
if (item?.link === link) {
return item.pageTitle || item.name || '';
}
if (item?.submenu?.length) {
const found = findLabel(item.submenu, link);
if (found) return found;
}
}
return '';
}
export default {
name: 'BreadcrumbsComponent',
setup() {
const route = useRoute();
const store = useStore();
const crumbs = computed(() => {
const path = route.path || '/';
const list = [{ label: 'Startseite', to: '/' }];
if (path === '/') return [];
const label = findLabel(store.state.menuData || [], path);
list.push({ label: label || 'Seite', to: path });
return list;
});
return { crumbs };
},
};
</script>
<style scoped>
.breadcrumbs {
margin: 0 var(--space-3) var(--space-2) var(--space-3);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.breadcrumbs ol {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0;
padding: 0;
}
.breadcrumbs li {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.breadcrumbs li:not(:last-child)::after {
content: '';
color: var(--color-text-muted);
}
.breadcrumbs a {
color: var(--color-text-muted);
text-decoration: none;
}
.breadcrumbs a:hover,
.breadcrumbs a:focus-visible {
color: var(--color-text);
text-decoration: underline;
text-underline-offset: 2px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More