feat: Implement blog and blog post models, routes, and services
- Added Blog and BlogPost models with necessary fields and relationships. - Created blogRouter for handling blog-related API endpoints including CRUD operations. - Developed BlogService for business logic related to blogs and posts, including sharing functionality. - Implemented API client methods for frontend to interact with blog-related endpoints. - Added internationalization support for blog-related text in English and German. - Created Vue components for blog editing, listing, and viewing, including a rich text editor for post content. - Enhanced user experience with form validations and dynamic visibility settings based on user input.
This commit is contained in:
201
frontend/src/views/blog/BlogEditorView.vue
Normal file
201
frontend/src/views/blog/BlogEditorView.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="blog-editor">
|
||||
<h1>{{ isEdit ? 'Blog bearbeiten' : 'Blog erstellen' }}</h1>
|
||||
<form @submit.prevent="save">
|
||||
<div>
|
||||
<label>Titel</label>
|
||||
<input v-model="form.title" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>Beschreibung</label>
|
||||
<textarea v-model="form.description"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Sichtbarkeit</label>
|
||||
<select v-model="form.visibility">
|
||||
<option value="public">Öffentlich</option>
|
||||
<option value="logged_in">Nur eingeloggte Nutzer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="form.visibility === 'logged_in'">
|
||||
<label>Altersbereich</label>
|
||||
<div class="row">
|
||||
<input type="number" min="0" v-model.number="form.ageMin" placeholder="min" />
|
||||
<input type="number" min="0" v-model.number="form.ageMax" placeholder="max" />
|
||||
</div>
|
||||
<label>Geschlecht</label>
|
||||
<div class="row">
|
||||
<label><input type="checkbox" value="m" v-model="genderSel"> Männlich</label>
|
||||
<label><input type="checkbox" value="f" v-model="genderSel"> Weiblich</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Speichern</button>
|
||||
</form>
|
||||
|
||||
<div v-if="isEdit" class="post-editor">
|
||||
<h2>Neuer Beitrag</h2>
|
||||
<form @submit.prevent="addPost">
|
||||
<input v-model="post.title" placeholder="Titel" required />
|
||||
<RichTextEditor v-model="post.content" :blog-id="$route.params.id" />
|
||||
<button class="btn" type="submit">Beitrag hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="isEdit" class="share-section">
|
||||
<h2>Blog teilen</h2>
|
||||
<div class="share-url">
|
||||
<label>URL</label>
|
||||
<input :value="currentShareUrl" readonly @focus="$event.target.select()" />
|
||||
<button class="btn" type="button" @click="copyUrl">Link kopieren</button>
|
||||
</div>
|
||||
<div class="share-actions">
|
||||
<button class="btn" type="button" @click="shareToFriends">An Freunde senden</button>
|
||||
</div>
|
||||
<div class="share-email">
|
||||
<label>E-Mail-Adressen (Kommagetrennt)</label>
|
||||
<input v-model="emailInput" placeholder="name@example.com, second@example.org" />
|
||||
<button class="btn" type="button" @click="shareToEmails">Senden</button>
|
||||
<p v-if="form.visibility !== 'public'" class="hint">Hinweis: Dieser Blog ist nicht öffentlich. Empfänger benötigen ggf. ein Login und passende Alters/Geschlechts-Berechtigung.</p>
|
||||
</div>
|
||||
<p v-if="shareStatus" class="status">{{ shareStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createBlog, updateBlog, getBlog, createPost, shareBlog } from '@/api/blogApi.js';
|
||||
import RichTextEditor from './components/RichTextEditor.vue';
|
||||
export default {
|
||||
name: 'BlogEditorView',
|
||||
components: { RichTextEditor },
|
||||
computed: {
|
||||
isEdit() { return !!this.$route.params.id; },
|
||||
isOwner() {
|
||||
const u = this.$store.getters.user;
|
||||
return !!(u && this.ownerHashedId && this.ownerHashedId === u.id);
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
form: { title: '', description: '', visibility: 'public', ageMin: null, ageMax: null },
|
||||
genderSel: [],
|
||||
post: { title: '', content: '' },
|
||||
ownerHashedId: null,
|
||||
emailInput: '',
|
||||
shareStatus: '',
|
||||
currentShareUrl: '',
|
||||
ownerUsername: '',
|
||||
}),
|
||||
async mounted() {
|
||||
if (this.isEdit) {
|
||||
const b = await getBlog(this.$route.params.id).catch(() => null);
|
||||
if (!b) return this.$router.replace('/blogs');
|
||||
this.ownerHashedId = b.owner?.hashedId || null;
|
||||
this.ownerUsername = b.owner?.username || '';
|
||||
if (!this.isOwner) return this.$router.replace(`/blogs/${this.$route.params.id}`);
|
||||
this.form = {
|
||||
title: b.title,
|
||||
description: b.description,
|
||||
visibility: b.visibility,
|
||||
ageMin: b.ageMin,
|
||||
ageMax: b.ageMax,
|
||||
};
|
||||
this.genderSel = (b.genders ? b.genders.split(',').filter(Boolean) : []);
|
||||
this.currentShareUrl = this.buildSlugUrl(b.title);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async save() {
|
||||
if (this.form.visibility === 'logged_in') {
|
||||
if (this.form.ageMin != null && this.form.ageMax != null && this.form.ageMin > this.form.ageMax) {
|
||||
alert('Ungültiger Altersbereich');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const payload = { ...this.form, genders: this.genderSel };
|
||||
if (this.isEdit) {
|
||||
await updateBlog(this.$route.params.id, payload);
|
||||
this.$router.push(`/blogs/${this.$route.params.id}`);
|
||||
} else {
|
||||
const b = await createBlog(payload);
|
||||
this.$router.push(`/blogs/${b.id}`);
|
||||
}
|
||||
},
|
||||
async addPost() {
|
||||
if (!this.isEdit) return;
|
||||
await createPost(this.$route.params.id, this.post);
|
||||
this.post = { title: '', content: '' };
|
||||
// optional: navigate to view; keep simple for now
|
||||
},
|
||||
blogAbsoluteUrl() {
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const uname = (this.ownerUsername || this.$store.getters.user?.username || '').toString();
|
||||
const titlePart = (this.form.title||'').toString().replace(/\s+/g, '').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const slug = `${uname}${titlePart}`.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return `${origin}/blogs/${encodeURIComponent(slug)}`;
|
||||
} catch {
|
||||
const uname = (this.ownerUsername || this.$store.getters.user?.username || '').toString();
|
||||
const titlePart = (this.form.title||'').toString().replace(/\s+/g, '').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const slug = `${uname}${titlePart}`.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return `/blogs/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
},
|
||||
buildSlugUrl(title) {
|
||||
const origin = window.location.origin;
|
||||
const uname = (this.ownerUsername || this.$store.getters.user?.username || '').toString();
|
||||
const titlePart = (title || '').toString().replace(/\s+/g, '').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const base = `${uname}${titlePart}`.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return `${origin}/blogs/${encodeURIComponent(base)}`;
|
||||
},
|
||||
async copyUrl() {
|
||||
const url = this.currentShareUrl || this.blogAbsoluteUrl();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.shareStatus = 'Link kopiert';
|
||||
} catch {
|
||||
this.shareStatus = 'Kopieren fehlgeschlagen';
|
||||
}
|
||||
setTimeout(() => (this.shareStatus = ''), 2000);
|
||||
},
|
||||
async shareToFriends() {
|
||||
try {
|
||||
const res = await shareBlog(this.$route.params.id, { toFriends: true });
|
||||
if (res.url) this.currentShareUrl = res.url;
|
||||
this.shareStatus = `An ${res.notifiedFriends || 0} Freund(e) gesendet.`;
|
||||
} catch (e) {
|
||||
this.shareStatus = 'Teilen fehlgeschlagen';
|
||||
}
|
||||
setTimeout(() => (this.shareStatus = ''), 3000);
|
||||
},
|
||||
async shareToEmails() {
|
||||
const emails = this.emailInput.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!emails.length) return;
|
||||
try {
|
||||
const res = await shareBlog(this.$route.params.id, { emails });
|
||||
if (res.url) this.currentShareUrl = res.url;
|
||||
this.shareStatus = `${res.emailsSent || 0} E-Mail(s) versendet.`;
|
||||
} catch (e) {
|
||||
this.shareStatus = 'E-Mail-Versand fehlgeschlagen';
|
||||
}
|
||||
setTimeout(() => (this.shareStatus = ''), 3000);
|
||||
}
|
||||
}
|
||||
,
|
||||
watch: {
|
||||
'form.title'(t) {
|
||||
if (this.isEdit) this.currentShareUrl = this.buildSlugUrl(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.btn { margin-top: .5rem; }
|
||||
.post-editor, .share-section { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; }
|
||||
.share-url { display: flex; align-items: center; gap: .5rem; }
|
||||
.share-url input { flex: 1; }
|
||||
.share-email { margin-top: .5rem; }
|
||||
.hint { color: #a66; font-size: .9em; }
|
||||
.status { color: #2a6; font-size: .95em; margin-top: .5rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user