diff --git a/backend/server.js b/backend/server.js index 90960246..fa4dbaeb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -69,6 +69,80 @@ function captureRawBody(req, res, buf, encoding) { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const SEO_DEFAULTS = { + title: 'Trainingstagebuch – Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere', + description: 'Trainingstagebuch ist die Software für Tischtennisvereine: Mitgliederverwaltung, Trainingsplanung, Trainingstagebuch, Gruppen, Turniere, Team-Management, Statistiken und MyTischtennis-Integration.', + robots: 'index,follow', +}; + +const SEO_ROUTE_CONFIG = { + '/': { + title: SEO_DEFAULTS.title, + description: SEO_DEFAULTS.description, + robots: 'index,follow', + }, + '/login': { + title: 'Login | Trainingstagebuch', + description: 'Im Trainingstagebuch einloggen und Vereinsdaten, Trainingsplanung, Mitglieder und Turniere verwalten.', + robots: 'noindex,follow', + }, + '/register': { + title: 'Registrieren | Trainingstagebuch', + description: 'Kostenlos im Trainingstagebuch registrieren und die Vereinsverwaltung für Tischtennisvereine kennenlernen.', + robots: 'noindex,follow', + }, + '/activate': { + title: 'Konto aktivieren | Trainingstagebuch', + description: 'Aktivierung des Benutzerkontos im Trainingstagebuch.', + robots: 'noindex,follow', + }, + '/forgot-password': { + title: 'Passwort vergessen | Trainingstagebuch', + description: 'Zugang zum Trainingstagebuch wiederherstellen.', + robots: 'noindex,follow', + }, + '/reset-password': { + title: 'Passwort zurücksetzen | Trainingstagebuch', + description: 'Passwort im Trainingstagebuch sicher zurücksetzen.', + robots: 'noindex,follow', + }, + '/impressum': { + title: 'Impressum | Trainingstagebuch', + description: 'Impressum von Trainingstagebuch.', + robots: 'noindex,follow', + }, + '/datenschutz': { + title: 'Datenschutzerklärung | Trainingstagebuch', + description: 'Datenschutzerklärung von Trainingstagebuch.', + robots: 'noindex,follow', + }, +}; + +function normalizeSeoPath(pathname = '/') { + if (!pathname || pathname === '') return '/'; + if (pathname === '/') return '/'; + return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +function getSeoConfigForPath(pathname = '/') { + const normalizedPath = normalizeSeoPath(pathname); + const matchedPrefix = Object.keys(SEO_ROUTE_CONFIG) + .filter((routePath) => routePath !== '/' && normalizedPath.startsWith(routePath)) + .sort((a, b) => b.length - a.length)[0]; + + return (matchedPrefix && SEO_ROUTE_CONFIG[matchedPrefix]) + || SEO_ROUTE_CONFIG[normalizedPath] + || { ...SEO_DEFAULTS, robots: 'noindex,follow' }; +} + +function escapeHtmlAttribute(value = '') { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + // CORS-Konfiguration - Socket.IO hat seine eigene CORS-Konfiguration app.use(cors({ origin: true, @@ -166,12 +240,53 @@ const setCanonicalTag = (req, res, next) => { const protocol = req.protocol || 'https'; const host = req.get('host') || 'tt-tagebuch.de'; const canonicalHost = host.replace(/^www\./, ''); // Entferne www falls vorhanden - const canonicalUrl = `${protocol}://${canonicalHost}${req.path === '/' ? '' : req.path}`; + const normalizedPath = normalizeSeoPath(req.path); + const canonicalUrl = `${protocol}://${canonicalHost}${normalizedPath === '/' ? '' : normalizedPath}`; + const seo = getSeoConfigForPath(normalizedPath); - // Ersetze den kanonischen Tag - const updatedData = data.replace( + let updatedData = data.replace( + /[^<]*<\/title>/, + `<title>${escapeHtmlAttribute(seo.title)}` + ); + + updatedData = updatedData.replace( + //, + `` + ); + + updatedData = updatedData.replace( + //, + `` + ); + + updatedData = updatedData.replace( //, - `` + `` + ); + + updatedData = updatedData.replace( + //, + `` + ); + + updatedData = updatedData.replace( + //, + `` + ); + + updatedData = updatedData.replace( + //, + `` + ); + + updatedData = updatedData.replace( + //, + `` + ); + + updatedData = updatedData.replace( + //, + `` ); res.setHeader('Content-Type', 'text/html'); diff --git a/frontend/index.html b/frontend/index.html index 6e7d44d3..de4df13f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,7 @@ Trainingstagebuch – Umfassende Vereinsverwaltung, Trainingsplanung & Turnierorganisation + diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index 3793f7f0..61b7395b 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -5,32 +5,8 @@ http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> https://tt-tagebuch.de/ - 2025-01-11 + 2026-03-18 weekly 1.0 - - https://tt-tagebuch.de/register - 2025-01-11 - monthly - 0.8 - - - https://tt-tagebuch.de/login - 2025-01-11 - monthly - 0.7 - - - https://tt-tagebuch.de/impressum - 2025-01-11 - yearly - 0.3 - - - https://tt-tagebuch.de/datenschutz - 2025-01-11 - yearly - 0.3 - diff --git a/frontend/src/router.js b/frontend/src/router.js index aec7833b..dcb9a80c 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -26,35 +26,36 @@ import MemberTransferSettingsView from './views/MemberTransferSettingsView.vue'; import PersonalSettings from './views/PersonalSettings.vue'; import Impressum from './views/Impressum.vue'; import Datenschutz from './views/Datenschutz.vue'; +import { applySeoForPath } from './utils/seo.js'; const routes = [ - { path: '/register', component: Register }, - { path: '/login', component: Login }, - { path: '/activate/:activationCode', component: Activate }, - { path: '/forgot-password', component: ForgotPassword }, - { path: '/reset-password/:token', component: ResetPassword }, - { path: '/', component: Home }, - { path: '/createclub', component: CreateClub }, - { path: '/showclub/:clubId', component: ClubView }, - { path: '/members', component: MembersView }, - { path: '/diary', component: DiaryView }, - { path: '/pending-approvals', component: PendingApprovalsView}, - { path: '/schedule', component: ScheduleView}, - { path: '/tournaments', component: TournamentsView }, - { path: '/tournament-participations', component: OfficialTournaments }, - { path: '/training-stats', component: TrainingStatsView }, - { path: '/club-settings', component: ClubSettings }, - { path: '/predefined-activities', component: PredefinedActivities }, - { path: '/mytischtennis-account', component: MyTischtennisAccount }, - { path: '/clicktt-account', component: ClickTtAccount }, - { path: '/team-management', component: TeamManagementView }, - { path: '/permissions', component: PermissionsView }, - { path: '/logs', component: LogsView }, - { path: '/clicktt', component: ClickTtView }, - { path: '/member-transfer-settings', component: MemberTransferSettingsView }, - { path: '/personal-settings', component: PersonalSettings }, - { path: '/impressum', component: Impressum }, - { path: '/datenschutz', component: Datenschutz }, + { path: '/register', name: 'register', component: Register }, + { path: '/login', name: 'login', component: Login }, + { path: '/activate/:activationCode', name: 'activate', component: Activate }, + { path: '/forgot-password', name: 'forgot-password', component: ForgotPassword }, + { path: '/reset-password/:token', name: 'reset-password', component: ResetPassword }, + { path: '/', name: 'home', component: Home }, + { path: '/createclub', name: 'create-club', component: CreateClub }, + { path: '/showclub/:clubId', name: 'show-club', component: ClubView }, + { path: '/members', name: 'members', component: MembersView }, + { path: '/diary', name: 'diary', component: DiaryView }, + { path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView}, + { path: '/schedule', name: 'schedule', component: ScheduleView}, + { path: '/tournaments', name: 'tournaments', component: TournamentsView }, + { path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments }, + { path: '/training-stats', name: 'training-stats', component: TrainingStatsView }, + { path: '/club-settings', name: 'club-settings', component: ClubSettings }, + { path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities }, + { path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount }, + { path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount }, + { path: '/team-management', name: 'team-management', component: TeamManagementView }, + { path: '/permissions', name: 'permissions', component: PermissionsView }, + { path: '/logs', name: 'logs', component: LogsView }, + { path: '/clicktt', name: 'clicktt', component: ClickTtView }, + { path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView }, + { path: '/personal-settings', name: 'personal-settings', component: PersonalSettings }, + { path: '/impressum', name: 'impressum', component: Impressum }, + { path: '/datenschutz', name: 'datenschutz', component: Datenschutz }, ]; const router = createRouter({ @@ -62,4 +63,8 @@ const router = createRouter({ routes, }); +router.afterEach((to) => { + applySeoForPath(to.path); +}); + export default router; diff --git a/frontend/src/utils/seo.js b/frontend/src/utils/seo.js new file mode 100644 index 00000000..1e6028f5 --- /dev/null +++ b/frontend/src/utils/seo.js @@ -0,0 +1,127 @@ +const SITE_NAME = 'Trainingstagebuch'; +const SITE_URL = 'https://tt-tagebuch.de'; +const DEFAULT_IMAGE = `${SITE_URL}/android-chrome-512x512.png`; + +const DEFAULT_SEO = { + title: 'Trainingstagebuch – Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere', + description: 'Trainingstagebuch ist die Software für Tischtennisvereine: Mitgliederverwaltung, Trainingsplanung, Trainingstagebuch, Gruppen, Turniere, Team-Management, Statistiken und MyTischtennis-Integration.', + robots: 'index,follow' +}; + +const ROUTE_SEO = { + '/': { + title: DEFAULT_SEO.title, + description: DEFAULT_SEO.description, + robots: 'index,follow' + }, + '/login': { + title: 'Login | Trainingstagebuch', + description: 'Im Trainingstagebuch einloggen und Vereinsdaten, Trainingsplanung, Mitglieder und Turniere verwalten.', + robots: 'noindex,follow' + }, + '/register': { + title: 'Registrieren | Trainingstagebuch', + description: 'Kostenlos im Trainingstagebuch registrieren und die Vereinsverwaltung für Tischtennisvereine kennenlernen.', + robots: 'noindex,follow' + }, + '/activate': { + title: 'Konto aktivieren | Trainingstagebuch', + description: 'Aktivierung des Benutzerkontos im Trainingstagebuch.', + robots: 'noindex,follow' + }, + '/forgot-password': { + title: 'Passwort vergessen | Trainingstagebuch', + description: 'Zugang zum Trainingstagebuch wiederherstellen.', + robots: 'noindex,follow' + }, + '/reset-password': { + title: 'Passwort zurücksetzen | Trainingstagebuch', + description: 'Passwort im Trainingstagebuch sicher zurücksetzen.', + robots: 'noindex,follow' + }, + '/impressum': { + title: 'Impressum | Trainingstagebuch', + description: 'Impressum von Trainingstagebuch.', + robots: 'noindex,follow' + }, + '/datenschutz': { + title: 'Datenschutzerklärung | Trainingstagebuch', + description: 'Datenschutzerklärung von Trainingstagebuch.', + robots: 'noindex,follow' + } +}; + +function normalizePath(path = '/') { + if (!path || path === '') return '/'; + if (path === '/') return '/'; + return path.endsWith('/') ? path.slice(0, -1) : path; +} + +export function getSeoConfigForPath(path) { + const normalizedPath = normalizePath(path); + const matchedPrefix = Object.keys(ROUTE_SEO) + .filter((routePath) => routePath !== '/' && normalizedPath.startsWith(routePath)) + .sort((a, b) => b.length - a.length)[0]; + + const routeSeo = (matchedPrefix && ROUTE_SEO[matchedPrefix]) || ROUTE_SEO[normalizedPath] || DEFAULT_SEO; + const canonicalPath = normalizedPath === '/' ? '' : normalizedPath; + + return { + title: routeSeo.title || DEFAULT_SEO.title, + description: routeSeo.description || DEFAULT_SEO.description, + robots: routeSeo.robots || DEFAULT_SEO.robots, + canonical: `${SITE_URL}${canonicalPath}`, + url: `${SITE_URL}${canonicalPath}`, + image: DEFAULT_IMAGE + }; +} + +function upsertMeta(selector, attributes) { + let element = document.head.querySelector(selector); + + if (!element) { + element = document.createElement('meta'); + document.head.appendChild(element); + } + + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, value); + }); +} + +function upsertLink(selector, attributes) { + let element = document.head.querySelector(selector); + + if (!element) { + element = document.createElement('link'); + document.head.appendChild(element); + } + + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, value); + }); +} + +export function applySeoForPath(path) { + if (typeof document === 'undefined') { + return; + } + + const seo = getSeoConfigForPath(path); + + document.title = seo.title; + + upsertMeta('meta[name="description"]', { name: 'description', content: seo.description }); + upsertMeta('meta[name="robots"]', { name: 'robots', content: seo.robots }); + upsertMeta('meta[property="og:type"]', { property: 'og:type', content: 'website' }); + upsertMeta('meta[property="og:site_name"]', { property: 'og:site_name', content: SITE_NAME }); + upsertMeta('meta[property="og:title"]', { property: 'og:title', content: seo.title }); + upsertMeta('meta[property="og:description"]', { property: 'og:description', content: seo.description }); + upsertMeta('meta[property="og:url"]', { property: 'og:url', content: seo.url }); + upsertMeta('meta[property="og:image"]', { property: 'og:image', content: seo.image }); + upsertMeta('meta[name="twitter:card"]', { name: 'twitter:card', content: 'summary_large_image' }); + upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title', content: seo.title }); + upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description', content: seo.description }); + upsertMeta('meta[name="twitter:image"]', { name: 'twitter:image', content: seo.image }); + upsertLink('link[rel="canonical"]', { rel: 'canonical', href: seo.canonical }); +}