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.
@@ -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 @@ + + +