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>/,
+ `${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 });
+}