From 59869e077e64b8a988015699bd14ae9ccc4fa089 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 18 Mar 2026 22:02:44 +0100 Subject: [PATCH] Update SEO and meta tags in index.html, enhance robots.txt for better crawling control, and improve sitemap.xml priorities. Refactor blog routes to include SEO metadata and adjust blog view for canonical URLs. Implement blog URL generation in BlogListView and apply SEO dynamically in BlogView. --- frontend/index.html | 61 ++----- frontend/public/robots.txt | 30 +++- frontend/public/sitemap.xml | 24 +-- frontend/src/App.vue | 3 - frontend/src/router/authRoutes.js | 5 +- frontend/src/router/blogRoutes.js | 50 +++++- frontend/src/router/index.js | 32 +++- frontend/src/router/marketingRoutes.js | 85 ++++++++++ frontend/src/utils/seo.js | 158 ++++++++++++++++++ frontend/src/views/blog/BlogListView.vue | 11 +- frontend/src/views/blog/BlogView.vue | 81 ++++++++- .../src/views/public/FalukantLandingView.vue | 100 +++++++++++ .../src/views/public/MinigamesLandingView.vue | 98 +++++++++++ .../src/views/public/VocabLandingView.vue | 102 +++++++++++ 14 files changed, 759 insertions(+), 81 deletions(-) create mode 100644 frontend/src/router/marketingRoutes.js create mode 100644 frontend/src/utils/seo.js create mode 100644 frontend/src/views/public/FalukantLandingView.vue create mode 100644 frontend/src/views/public/MinigamesLandingView.vue create mode 100644 frontend/src/views/public/VocabLandingView.vue diff --git a/frontend/index.html b/frontend/index.html index ccd3b7f..13dd7d0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,6 +9,7 @@ + @@ -16,63 +17,29 @@ + - + + + + -
+ - \ No newline at end of file + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 3327efb..3309037 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -1,6 +1,34 @@ User-agent: * Allow: / +Disallow: /activate +Disallow: /admin/ +Disallow: /friends +Disallow: /personal/ +Disallow: /settings/ +Disallow: /socialnetwork/diary +Disallow: /socialnetwork/forum/ +Disallow: /socialnetwork/forumtopic/ +Disallow: /socialnetwork/gallery +Disallow: /socialnetwork/guestbook +Disallow: /socialnetwork/search +Disallow: /socialnetwork/vocab/ +Disallow: /falukant/home +Disallow: /falukant/create +Disallow: /falukant/branch/ +Disallow: /falukant/moneyhistory +Disallow: /falukant/family +Disallow: /falukant/house +Disallow: /falukant/nobility +Disallow: /falukant/reputation +Disallow: /falukant/church +Disallow: /falukant/education +Disallow: /falukant/bank +Disallow: /falukant/directors +Disallow: /falukant/health +Disallow: /falukant/politics +Disallow: /falukant/darknet +Disallow: /minigames/match3 +Disallow: /minigames/taxi Sitemap: https://www.your-part.de/sitemap.xml - diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index 83fd8a6..b4fa7ef 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -8,6 +8,11 @@ https://www.your-part.de/blogs daily + 0.9 + + + https://www.your-part.de/vokabeltrainer + weekly 0.8 @@ -16,25 +21,8 @@ 0.8 - https://www.your-part.de/socialnetwork/vocab + https://www.your-part.de/minigames weekly 0.7 - - https://www.your-part.de/minigames - monthly - 0.6 - - - https://www.your-part.de/minigames/match3 - monthly - 0.5 - - - https://www.your-part.de/minigames/taxi - monthly - 0.5 - - - diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 23298ca..d0b0497 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -42,9 +42,6 @@ import MultiChatDialog from './dialogues/chat/MultiChatDialog.vue'; export default { name: 'App', - mounted() { - document.title = 'yourPart'; - }, computed: { ...mapGetters(['isLoggedIn', 'user']) }, diff --git a/frontend/src/router/authRoutes.js b/frontend/src/router/authRoutes.js index 35c546f..a7cbbf3 100644 --- a/frontend/src/router/authRoutes.js +++ b/frontend/src/router/authRoutes.js @@ -4,7 +4,10 @@ const authRoutes = [ { path: '/activate', name: 'Activate page', - component: ActivateView + component: ActivateView, + meta: { + robots: 'noindex, nofollow' + } }, ]; diff --git a/frontend/src/router/blogRoutes.js b/frontend/src/router/blogRoutes.js index fb4bafc..428a8ee 100644 --- a/frontend/src/router/blogRoutes.js +++ b/frontend/src/router/blogRoutes.js @@ -6,8 +6,52 @@ export default [ { path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } }, { path: '/blogs/:id/edit', name: 'BlogEdit', component: BlogEditorView, props: true, meta: { requiresAuth: true } }, // Slug-only route first so it doesn't get captured by the :id route - { path: '/blogs/:slug', name: 'BlogSlug', component: BlogView, props: route => ({ slug: route.params.slug }) }, + { + path: '/blogs/:slug', + name: 'BlogSlug', + component: BlogView, + props: route => ({ slug: route.params.slug }), + meta: { + seo: { + title: 'Blogs auf YourPart', + description: 'Oeffentliche Blogs, Beitraege und Community-Inhalte auf YourPart.', + }, + }, + }, // Id-constrained route (numeric id only) with optional slug for canonical links - { path: '/blogs/:id(\\d+)/:slug?', name: 'Blog', component: BlogView, props: true }, - { path: '/blogs', name: 'BlogList', component: BlogListView }, + { + path: '/blogs/:id(\\d+)/:slug?', + name: 'Blog', + component: BlogView, + props: true, + meta: { + seo: { + title: 'Blogs auf YourPart', + description: 'Oeffentliche Blogs, Beitraege und Community-Inhalte auf YourPart.', + }, + }, + }, + { + path: '/blogs', + name: 'BlogList', + component: BlogListView, + meta: { + seo: { + title: 'Blogs auf YourPart - Community-Beitraege und Themen', + description: 'Entdecke oeffentliche Blogs auf YourPart mit Community-Beitraegen, Gedanken, Erfahrungen und Themen aus verschiedenen Bereichen.', + keywords: 'Blogs, Community Blog, Artikel, Beitraege, YourPart', + canonicalPath: '/blogs', + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: 'Blogs auf YourPart', + url: 'https://www.your-part.de/blogs', + description: 'Oeffentliche Blogs und Community-Beitraege auf YourPart.', + inLanguage: 'de', + }, + ], + }, + }, + }, ]; diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index f96fe7c..0937f83 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -9,13 +9,39 @@ import falukantRoutes from './falukantRoutes'; import blogRoutes from './blogRoutes'; import minigamesRoutes from './minigamesRoutes'; import personalRoutes from './personalRoutes'; +import marketingRoutes from './marketingRoutes'; +import { applyRouteSeo } from '../utils/seo'; const routes = [ { path: '/', name: 'Home', - component: HomeView + component: HomeView, + meta: { + seo: { + title: 'YourPart - Community, Chat, Forum, Blogs, Vokabeltrainer und Spiele', + description: 'YourPart ist eine Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, dem Browser-Aufbauspiel Falukant und Minispielen.', + keywords: 'YourPart, Community, Chat, Forum, Blogs, Vokabeltrainer, Browsergame, Falukant, Minispiele', + canonicalPath: '/', + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'YourPart', + url: 'https://www.your-part.de/', + inLanguage: 'de', + description: 'Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen.', + potentialAction: { + '@type': 'SearchAction', + target: 'https://www.your-part.de/blogs?q={search_term_string}', + 'query-input': 'required name=search_term_string', + }, + }, + ], + }, + }, }, + ...marketingRoutes, ...authRoutes, ...socialRoutes, ...settingsRoutes, @@ -45,4 +71,8 @@ router.beforeEach((to, from, next) => { } }); +router.afterEach((to) => { + applyRouteSeo(to); +}); + export default router; diff --git a/frontend/src/router/marketingRoutes.js b/frontend/src/router/marketingRoutes.js new file mode 100644 index 0000000..c8c98e8 --- /dev/null +++ b/frontend/src/router/marketingRoutes.js @@ -0,0 +1,85 @@ +const FalukantLandingView = () => import('../views/public/FalukantLandingView.vue'); +const MinigamesLandingView = () => import('../views/public/MinigamesLandingView.vue'); +const VocabLandingView = () => import('../views/public/VocabLandingView.vue'); + +const marketingRoutes = [ + { + path: '/falukant', + name: 'FalukantLanding', + component: FalukantLandingView, + meta: { + seo: { + title: 'Falukant - Mittelalterliches Browser-Aufbauspiel auf YourPart', + description: 'Falukant ist das mittelalterliche Browser-Aufbauspiel auf YourPart mit Handel, Politik, Familie, Bildung und Charakterentwicklung.', + keywords: 'Falukant, Browsergame, Aufbauspiel, Mittelalterspiel, Wirtschaftsspiel, Politikspiel, YourPart', + canonicalPath: '/falukant', + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'VideoGame', + name: 'Falukant', + url: 'https://www.your-part.de/falukant', + description: 'Mittelalterliches Browser-Aufbauspiel mit Handel, Politik, Familie und Charakterentwicklung.', + gamePlatform: 'Web Browser', + applicationCategory: 'Game', + inLanguage: 'de', + publisher: { + '@type': 'Organization', + name: 'YourPart', + }, + }, + ], + }, + }, + }, + { + path: '/minigames', + name: 'MinigamesLanding', + component: MinigamesLandingView, + meta: { + seo: { + title: 'Minispiele auf YourPart - Match 3 und Taxi im Browser', + description: 'Entdecke die Browser-Minispiele auf YourPart: Match 3 und Taxi bieten schnelle Spielrunden direkt auf der Plattform.', + keywords: 'Minispiele, Browsergames, Match 3, Taxi Spiel, Casual Games, YourPart', + canonicalPath: '/minigames', + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: 'YourPart Minispiele', + url: 'https://www.your-part.de/minigames', + description: 'Browser-Minispiele auf YourPart mit Match 3 und Taxi.', + inLanguage: 'de', + }, + ], + }, + }, + }, + { + path: '/vokabeltrainer', + name: 'VocabLanding', + component: VocabLandingView, + meta: { + seo: { + title: 'Vokabeltrainer auf YourPart - Sprachen online lernen', + description: 'Der Vokabeltrainer auf YourPart unterstützt dich beim Sprachenlernen mit interaktiven Lektionen, Kursen und Übungen.', + keywords: 'Vokabeltrainer, Sprachen lernen, Online lernen, Sprachkurse, Übungen, YourPart', + canonicalPath: '/vokabeltrainer', + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'YourPart Vokabeltrainer', + url: 'https://www.your-part.de/vokabeltrainer', + description: 'Interaktiver Vokabeltrainer mit Kursen, Lektionen und Übungen zum Sprachenlernen.', + applicationCategory: 'EducationalApplication', + operatingSystem: 'Web', + inLanguage: 'de', + }, + ], + }, + }, + }, +]; + +export default marketingRoutes; diff --git a/frontend/src/utils/seo.js b/frontend/src/utils/seo.js new file mode 100644 index 0000000..9bae095 --- /dev/null +++ b/frontend/src/utils/seo.js @@ -0,0 +1,158 @@ +const DEFAULT_BASE_URL = 'https://www.your-part.de'; +const DEFAULT_SITE_NAME = 'YourPart'; +const DEFAULT_TITLE = 'YourPart - Community, Chat, Forum, Vokabeltrainer, Falukant und Minispiele'; +const DEFAULT_DESCRIPTION = 'YourPart verbindet Community, Chat, Forum, Blogs, Vokabeltrainer, das Aufbauspiel Falukant und Browser-Minispiele auf einer Plattform.'; +const DEFAULT_IMAGE = `${DEFAULT_BASE_URL}/images/logos/logo.png`; +const MANAGED_META_KEYS = [ + ['name', 'description'], + ['name', 'keywords'], + ['name', 'robots'], + ['name', 'twitter:card'], + ['name', 'twitter:title'], + ['name', 'twitter:description'], + ['name', 'twitter:image'], + ['property', 'og:type'], + ['property', 'og:site_name'], + ['property', 'og:title'], + ['property', 'og:description'], + ['property', 'og:url'], + ['property', 'og:locale'], + ['property', 'og:image'], +]; + +function getBaseUrl() { + return (import.meta.env.VITE_PUBLIC_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, ''); +} + +function upsertMeta(attr, key, content) { + let element = document.head.querySelector(`meta[${attr}="${key}"]`); + if (!element) { + element = document.createElement('meta'); + element.setAttribute(attr, key); + document.head.appendChild(element); + } + element.setAttribute('content', content); +} + +function upsertLink(rel, href) { + let element = document.head.querySelector(`link[rel="${rel}"]`); + if (!element) { + element = document.createElement('link'); + element.setAttribute('rel', rel); + document.head.appendChild(element); + } + element.setAttribute('href', href); +} + +function clearManagedJsonLd() { + document.head.querySelectorAll('script[data-seo-managed="true"]').forEach((node) => node.remove()); +} + +export function buildAbsoluteUrl(path = '/') { + if (/^https?:\/\//i.test(path)) { + return path; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${getBaseUrl()}${normalizedPath}`; +} + +export function stripHtml(html = '') { + return html + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function truncateText(text = '', maxLength = 160) { + if (text.length <= maxLength) { + return text; + } + + return `${text.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +export function createBlogSlug(ownerUsername = '', blogTitle = '') { + const usernamePart = String(ownerUsername || '').trim(); + const titlePart = String(blogTitle || '') + .replace(/\s+/g, '') + .trim(); + + return `${usernamePart}${titlePart}`.replace(/[^a-zA-Z0-9_-]/g, ''); +} + +export function applySeo(overrides = {}) { + const title = overrides.title || DEFAULT_TITLE; + const description = overrides.description || DEFAULT_DESCRIPTION; + const keywords = overrides.keywords || 'YourPart, Community, Chat, Forum, Blog, Vokabeltrainer, Falukant, Minispiele'; + const robots = overrides.robots || 'index, follow'; + const canonical = buildAbsoluteUrl(overrides.canonicalPath || '/'); + const image = buildAbsoluteUrl(overrides.image || DEFAULT_IMAGE); + const type = overrides.type || 'website'; + const locale = overrides.locale || 'de_DE'; + + document.title = title; + document.documentElement.setAttribute('lang', overrides.lang || 'de'); + + upsertMeta('name', 'description', description); + upsertMeta('name', 'keywords', keywords); + upsertMeta('name', 'robots', robots); + upsertMeta('name', 'twitter:card', overrides.twitterCard || 'summary_large_image'); + upsertMeta('name', 'twitter:title', overrides.twitterTitle || title); + upsertMeta('name', 'twitter:description', overrides.twitterDescription || description); + upsertMeta('name', 'twitter:image', image); + upsertMeta('property', 'og:type', type); + upsertMeta('property', 'og:site_name', DEFAULT_SITE_NAME); + upsertMeta('property', 'og:title', overrides.ogTitle || title); + upsertMeta('property', 'og:description', overrides.ogDescription || description); + upsertMeta('property', 'og:url', canonical); + upsertMeta('property', 'og:locale', locale); + upsertMeta('property', 'og:image', image); + upsertLink('canonical', canonical); + + clearManagedJsonLd(); + for (const payload of overrides.jsonLd || []) { + const script = document.createElement('script'); + script.type = 'application/ld+json'; + script.dataset.seoManaged = 'true'; + script.textContent = JSON.stringify(payload); + document.head.appendChild(script); + } +} + +export function applyRouteSeo(route) { + const seo = route.meta?.seo || {}; + const isProtected = !!route.meta?.requiresAuth; + + const title = seo.title || DEFAULT_TITLE; + const description = seo.description || DEFAULT_DESCRIPTION; + const canonicalPath = seo.canonicalPath || route.path || '/'; + const robots = seo.robots || route.meta?.robots || (isProtected ? 'noindex, nofollow' : 'index, follow'); + + applySeo({ + title, + description, + canonicalPath, + keywords: seo.keywords, + robots, + type: seo.type, + image: seo.image, + locale: seo.locale, + lang: seo.lang, + jsonLd: isProtected ? [] : (seo.jsonLd || []), + }); +} + +export function resetSeo() { + for (const [attr, key] of MANAGED_META_KEYS) { + const element = document.head.querySelector(`meta[${attr}="${key}"]`); + if (element) { + element.remove(); + } + } + + document.head.querySelector('link[rel="canonical"]')?.remove(); + clearManagedJsonLd(); +} diff --git a/frontend/src/views/blog/BlogListView.vue b/frontend/src/views/blog/BlogListView.vue index de47a58..11ce16a 100644 --- a/frontend/src/views/blog/BlogListView.vue +++ b/frontend/src/views/blog/BlogListView.vue @@ -9,7 +9,7 @@
Keine Blogs gefunden.
  • - {{ b.title }} + {{ b.title }} – {{ b.owner?.username }}
@@ -19,11 +19,18 @@ diff --git a/frontend/src/views/blog/BlogView.vue b/frontend/src/views/blog/BlogView.vue index 7ef4a9b..25450f5 100644 --- a/frontend/src/views/blog/BlogView.vue +++ b/frontend/src/views/blog/BlogView.vue @@ -37,6 +37,7 @@ import { getBlog, listPosts, createPost } from '@/api/blogApi.js'; import DOMPurify from 'dompurify'; import RichTextEditor from './components/RichTextEditor.vue'; +import { applySeo, buildAbsoluteUrl, createBlogSlug, stripHtml, truncateText } from '@/utils/seo.js'; export default { name: 'BlogView', props: { id: String, slug: String }, @@ -46,9 +47,73 @@ export default { isOwner() { const u = this.$store.getters.user; return !!(u && this.blog && this.blog.owner && this.blog.owner.hashedId === u.id); - } + }, + pages() { + return Math.max(1, Math.ceil(this.total / this.pageSize)); + }, }, async mounted() { + await this.loadBlog(); + }, + watch: { + '$route.fullPath': { + async handler() { + await this.loadBlog(); + }, + }, + }, + methods: { + canonicalBlogPath() { + const slug = createBlogSlug(this.blog?.owner?.username, this.blog?.title); + if (slug) { + return `/blogs/${encodeURIComponent(slug)}`; + } + + return this.blog?.id ? `/blogs/${this.blog.id}` : '/blogs'; + }, + applyBlogSeo() { + if (!this.blog) { + return; + } + + const plainTextPosts = this.items + .map((item) => `${item.title || ''} ${stripHtml(item.content || '')}`.trim()) + .filter(Boolean) + .join(' '); + const summarySource = this.blog.description || plainTextPosts || 'Oeffentlicher Community-Blog auf YourPart.'; + const description = truncateText(summarySource, 160); + const canonicalPath = this.canonicalBlogPath(); + + applySeo({ + title: `${this.blog.title} | Blog auf YourPart`, + description, + canonicalPath, + keywords: `Blog, ${this.blog.title}, ${this.blog.owner?.username || 'YourPart'}, Community`, + type: 'article', + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'Blog', + name: this.blog.title, + description, + url: buildAbsoluteUrl(canonicalPath), + inLanguage: 'de', + author: this.blog.owner?.username + ? { + '@type': 'Person', + name: this.blog.owner.username, + } + : undefined, + }, + ], + }); + }, + async loadBlog() { + this.loading = true; + this.blog = null; + this.items = []; + this.resolvedId = null; + try { let id = this.$route.params.id; // If we have a slug route param or the id is non-numeric, resolve to id @@ -67,12 +132,18 @@ export default { const useId = id || this.resolvedId; this.blog = await getBlog(useId); await this.fetchPage(1); + this.applyBlogSeo(); } catch (e) { console.log(e); // this.$router.replace('/blogs'); + applySeo({ + title: 'Blog nicht gefunden | YourPart', + description: 'Der angeforderte Blog konnte nicht geladen werden.', + canonicalPath: '/blogs', + robots: 'noindex, nofollow', + }); } finally { this.loading = false; } - }, - methods: { + }, sanitize(html) { return DOMPurify.sanitize(html || ''); }, @@ -83,8 +154,8 @@ export default { this.page = res.page; this.pageSize = res.pageSize; this.total = res.total; + this.applyBlogSeo(); }, - get pages() { return Math.max(1, Math.ceil(this.total / this.pageSize)); }, async go(p) { if (p>=1 && p<=this.pages) await this.fetchPage(p); }, async addPost() { if (!this.newPost.title || !this.newPost.content) return; @@ -106,4 +177,4 @@ export default { padding: 0.2em 0.5em; display: inline-block; } - \ No newline at end of file + diff --git a/frontend/src/views/public/FalukantLandingView.vue b/frontend/src/views/public/FalukantLandingView.vue new file mode 100644 index 0000000..fbe3bd9 --- /dev/null +++ b/frontend/src/views/public/FalukantLandingView.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/views/public/MinigamesLandingView.vue b/frontend/src/views/public/MinigamesLandingView.vue new file mode 100644 index 0000000..661788a --- /dev/null +++ b/frontend/src/views/public/MinigamesLandingView.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/views/public/VocabLandingView.vue b/frontend/src/views/public/VocabLandingView.vue new file mode 100644 index 0000000..330709f --- /dev/null +++ b/frontend/src/views/public/VocabLandingView.vue @@ -0,0 +1,102 @@ + + +