Files
yourpart3/frontend/src/views/blog/BlogView.vue

287 lines
8.0 KiB
Vue

<template>
<div class="blog-view">
<div v-if="loading" class="blog-view__state surface-card">Laden</div>
<div v-else-if="blog" class="blog-layout">
<section class="blog-hero surface-card">
<div>
<div class="meta">von {{ blog.owner?.username }}</div>
<h1>{{ blog.title }}</h1>
<p v-if="blog.description" class="blog-description">{{ blog.description }}</p>
</div>
<div v-if="$store.getters.isLoggedIn" class="actions">
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
</div>
</section>
<div class="blog-content">
<section class="posts surface-card">
<div class="posts__header">
<h2>{{ $t('blog.posts') }}</h2>
<span class="posts__count">{{ total }} Einträge</span>
</div>
<div v-if="!items.length" class="blog-view__state">Keine Einträge vorhanden.</div>
<article v-for="p in items" :key="p.id" class="post">
<h3>{{ p.title }}</h3>
<div class="content" v-html="sanitize(p.content)" />
</article>
<div class="pagination" v-if="total > pageSize">
<button :disabled="page===1" @click="go(page-1)">«</button>
<span>{{ page }} / {{ pages }}</span>
<button :disabled="page===pages" @click="go(page+1)">»</button>
</div>
</section>
<div v-if="isOwner" class="post-editor surface-card">
<h3>{{ $t('blog.newPost') }}</h3>
<form @submit.prevent="addPost">
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
<RichTextEditor v-model="newPost.content" :blog-id="blog.id" />
<button class="btn" type="submit">{{ $t('blog.publish') }}</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script>
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 },
components: { RichTextEditor },
data: () => ({ blog: null, items: [], page: 1, pageSize: 10, total: 0, loading: true, newPost: { title: '', content: '' }, resolvedId: null }),
computed: {
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 || 'Öffentlicher 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
if ((!id && this.$route.params.slug) || (id && isNaN(Number(id)))) {
const slug = this.$route.params.slug || this.$route.params.id;
// Resolve slug to id via backend resolver but keep slug URL
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL || ''}/api/blog/blogs/slug/${encodeURIComponent(slug)}/id`);
if (res.ok) {
const { id: rid } = await res.json();
this.resolvedId = rid;
id = rid;
} else {
throw new Error('slug not found');
}
}
const useId = id || this.resolvedId;
this.blog = await getBlog(useId);
await this.fetchPage(1);
this.applyBlogSeo();
} catch (e) {
console.error('Blog konnte nicht geladen werden:', 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; }
},
sanitize(html) {
return DOMPurify.sanitize(html || '');
},
async fetchPage(p) {
const id = this.$route.params.id || this.resolvedId;
const res = await listPosts(id, { page: p, pageSize: this.pageSize });
this.items = res.items;
this.page = res.page;
this.pageSize = res.pageSize;
this.total = res.total;
this.applyBlogSeo();
},
async go(p) { if (p>=1 && p<=this.pages) await this.fetchPage(p); },
async addPost() {
if (!this.newPost.title || !this.newPost.content) return;
const id = this.$route.params.id || this.resolvedId;
await createPost(id, this.newPost);
this.newPost = { title: '', content: '' };
await this.fetchPage(1);
}
}
}
</script>
<style lang="scss" scoped>
.blog-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.blog-layout {
display: grid;
gap: 18px;
}
.blog-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
padding: 26px;
}
.blog-description {
margin: 0;
max-width: 70ch;
color: var(--color-text-secondary);
}
.meta {
margin-bottom: 10px;
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.blog-content {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 0.95fr);
gap: 18px;
align-items: start;
}
.posts,
.post-editor {
padding: 24px;
}
.posts__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.posts__count {
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 700;
}
.post + .post {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--color-border);
}
.content {
color: var(--color-text-secondary);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
.blog-view__state {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.editbutton {
margin-bottom: 0;
display: inline-block;
}
@media (max-width: 960px) {
.blog-hero,
.blog-content {
grid-template-columns: 1fr;
display: grid;
}
.blog-hero {
padding: 20px;
}
.posts,
.post-editor {
padding: 18px;
}
}
</style>