287 lines
8.0 KiB
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>
|