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:
75
frontend/package-lock.json
generated
75
frontend/package-lock.json
generated
@@ -8,10 +8,16 @@
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-color": "^2.14.0",
|
||||
"@tiptap/extension-image": "^2.14.0",
|
||||
"@tiptap/extension-text-align": "^2.14.0",
|
||||
"@tiptap/extension-text-style": "^2.14.0",
|
||||
"@tiptap/extension-underline": "^2.14.0",
|
||||
"@tiptap/starter-kit": "^2.14.0",
|
||||
"@tiptap/vue-3": "^2.14.0",
|
||||
"axios": "^1.7.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -969,6 +975,20 @@
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-color": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.14.0.tgz",
|
||||
"integrity": "sha512-sY+eWIbkCMAwOGH7pQ1ZuNqkqMaaHE+TsJwA7bQ6VhI2gGhhqGjT/DfmJMUen8FSdzuPoWlgtuXXCeOO6FOduw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/extension-text-style": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.14.0.tgz",
|
||||
@@ -1081,6 +1101,19 @@
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.14.0.tgz",
|
||||
"integrity": "sha512-pYCUzZBgsxIvVGTzuW03cPz6PIrAo26xpoxqq4W090uMVoK0SgY5W5y0IqCdw4QyLkJ2/oNSFNc2EP9jVi1CcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.14.0.tgz",
|
||||
@@ -1159,6 +1192,19 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.14.0.tgz",
|
||||
"integrity": "sha512-9Wth4sAq2lYVWvQA0Qy095fsnPEavBv1FKWzVEyurwEQB7ZQsf/MRGmCNFnUXXy12w1G9UOanS4KkJ4C64+Ccw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.14.0.tgz",
|
||||
@@ -1172,6 +1218,19 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.14.0.tgz",
|
||||
"integrity": "sha512-rlBasbwElFikaL5qPyp3OeoEBH2p9Dve0K6liqIWF4i9cECH2Bm53y2S0enVEe01hmgQEWmoYK+fq67rxr3XsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.14.0.tgz",
|
||||
@@ -1283,6 +1342,13 @@
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
@@ -1612,6 +1678,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
||||
|
||||
@@ -8,10 +8,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-color": "^2.14.0",
|
||||
"@tiptap/extension-image": "^2.14.0",
|
||||
"@tiptap/extension-text-align": "^2.14.0",
|
||||
"@tiptap/extension-text-style": "^2.14.0",
|
||||
"@tiptap/extension-underline": "^2.14.0",
|
||||
"@tiptap/starter-kit": "^2.14.0",
|
||||
"@tiptap/vue-3": "^2.14.0",
|
||||
"axios": "^1.7.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
|
||||
19
frontend/src/api/blogApi.js
Normal file
19
frontend/src/api/blogApi.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import apiClient from '@/utils/axios.js';
|
||||
|
||||
export const listBlogs = async () => (await apiClient.get('/api/blog/blogs')).data;
|
||||
export const getBlog = async (id) => (await apiClient.get(`/api/blog/blogs/${id}`)).data;
|
||||
export const listPosts = async (id, { page = 1, pageSize = 10 } = {}) => (await apiClient.get(`/api/blog/blogs/${id}/posts`, { params: { page, pageSize } })).data;
|
||||
|
||||
export const createBlog = async (payload) => (await apiClient.post('/api/blog/blogs', payload)).data;
|
||||
export const updateBlog = async (id, payload) => (await apiClient.put(`/api/blog/blogs/${id}`, payload)).data;
|
||||
export const createPost = async (id, payload) => (await apiClient.post(`/api/blog/blogs/${id}/posts`, payload)).data;
|
||||
export const listBlogImages = async (id) => (await apiClient.get(`/api/blog/blogs/${id}/images`)).data;
|
||||
export const uploadBlogImage = async (id, file, meta = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
if (meta.title) formData.append('title', meta.title);
|
||||
if (meta.description) formData.append('description', meta.description);
|
||||
return (await apiClient.post(`/api/blog/blogs/${id}/images`, formData, { headers: { 'Content-Type': 'multipart/form-data' } })).data;
|
||||
};
|
||||
export const shareBlog = async (id, payload) => (await apiClient.post(`/api/blog/blogs/${id}/share`, payload)).data;
|
||||
|
||||
@@ -15,6 +15,7 @@ import enSocialNetwork from './locales/en/socialnetwork.json';
|
||||
import enFriends from './locales/en/friends.json';
|
||||
import enFalukant from './locales/en/falukant.json';
|
||||
import enPasswordReset from './locales/en/passwordReset.json';
|
||||
import enBlog from './locales/en/blog.json';
|
||||
|
||||
import deGeneral from './locales/de/general.json';
|
||||
import deHeader from './locales/de/header.json';
|
||||
@@ -30,6 +31,7 @@ import deSocialNetwork from './locales/de/socialnetwork.json';
|
||||
import deFriends from './locales/de/friends.json';
|
||||
import deFalukant from './locales/de/falukant.json';
|
||||
import dePasswordReset from './locales/de/passwordReset.json';
|
||||
import deBlog from './locales/de/blog.json';
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
@@ -47,6 +49,7 @@ const messages = {
|
||||
...enSocialNetwork,
|
||||
...enFriends,
|
||||
...enFalukant,
|
||||
...enBlog,
|
||||
},
|
||||
de: {
|
||||
'Ok': 'Ok',
|
||||
@@ -64,6 +67,7 @@ const messages = {
|
||||
...deSocialNetwork,
|
||||
...deFriends,
|
||||
...deFalukant,
|
||||
...deBlog,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
11
frontend/src/i18n/locales/de/blog.json
Normal file
11
frontend/src/i18n/locales/de/blog.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"blog": {
|
||||
"posts": "Beiträge",
|
||||
"noPosts": "Keine Beiträge.",
|
||||
"newPost": "Neuen Beitrag verfassen",
|
||||
"title": "Titel",
|
||||
"publish": "Veröffentlichen",
|
||||
"pickImage": "Bild auswählen",
|
||||
"uploadImage": "Bild hochladen"
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"m-socialnetwork": {
|
||||
"guestbook": "Gästebuch",
|
||||
"blog": "Blog",
|
||||
"usersearch": "Benutzersuche",
|
||||
"forum": "Forum",
|
||||
"gallery": "Galerie",
|
||||
|
||||
11
frontend/src/i18n/locales/en/blog.json
Normal file
11
frontend/src/i18n/locales/en/blog.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"blog": {
|
||||
"posts": "Posts",
|
||||
"noPosts": "No posts.",
|
||||
"newPost": "Write new post",
|
||||
"title": "Title",
|
||||
"publish": "Publish",
|
||||
"pickImage": "Pick an image",
|
||||
"uploadImage": "Upload image"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,79 @@
|
||||
{
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"services": "Services",
|
||||
"team": "Team",
|
||||
"company": "Company",
|
||||
"consulting": "Consulting",
|
||||
"development": "Development",
|
||||
"mailbox": "Mailbox",
|
||||
"logout": "Logout"
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"logout": "Logout",
|
||||
"friends": "Friends",
|
||||
"socialnetwork": "Meeting point",
|
||||
"chats": "Chats",
|
||||
"falukant": "Falukant",
|
||||
"minigames": "Mini games",
|
||||
"settings": "Settings",
|
||||
"administration": "Administration",
|
||||
"m-chats": {
|
||||
"multiChat": "Multiuser chat",
|
||||
"randomChat": "Random single chat",
|
||||
"eroticChat": "Erotic chat"
|
||||
},
|
||||
"m-socialnetwork": {
|
||||
"guestbook": "Guestbook",
|
||||
"blog": "Blog",
|
||||
"usersearch": "User search",
|
||||
"forum": "Forum",
|
||||
"gallery": "Gallery",
|
||||
"blockedUsers": "Blocked users",
|
||||
"oneTimeInvitation": "One-time invitations",
|
||||
"diary": "Diary",
|
||||
"erotic": "Erotic",
|
||||
"m-erotic": {
|
||||
"pictures": "Pictures",
|
||||
"videos": "Videos"
|
||||
}
|
||||
},
|
||||
"m-settings": {
|
||||
"homepage": "Homepage",
|
||||
"account": "Account",
|
||||
"personal": "Personal",
|
||||
"view": "Appearance",
|
||||
"flirt": "Flirt",
|
||||
"interests": "Interests",
|
||||
"notifications": "Notifications",
|
||||
"sexuality": "Sexuality"
|
||||
},
|
||||
"m-administration": {
|
||||
"contactrequests": "Contact requests",
|
||||
"useradministration": "User administration",
|
||||
"forum": "Forum",
|
||||
"userrights": "User rights",
|
||||
"interests": "Interests",
|
||||
"falukant": "Falukant",
|
||||
"m-falukant": {
|
||||
"logentries": "Log entries",
|
||||
"edituser": "Edit user",
|
||||
"database": "Database"
|
||||
}
|
||||
},
|
||||
"m-friends": {
|
||||
"manageFriends": "Manage friends",
|
||||
"chat": "Chat",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"m-falukant": {
|
||||
"create": "Create",
|
||||
"overview": "Overview",
|
||||
"towns": "Towns",
|
||||
"directors": "Directors",
|
||||
"factory": "Factory",
|
||||
"family": "Family",
|
||||
"house": "House",
|
||||
"darknet": "Underground",
|
||||
"reputation": "Reputation",
|
||||
"moneyhistory": "Money flow",
|
||||
"nobility": "Social status",
|
||||
"politics": "Politics",
|
||||
"education": "Education",
|
||||
"health": "Health",
|
||||
"bank": "Bank",
|
||||
"church": "Church"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
frontend/src/router/blogRoutes.js
Normal file
13
frontend/src/router/blogRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import BlogListView from '@/views/blog/BlogListView.vue';
|
||||
import BlogView from '@/views/blog/BlogView.vue';
|
||||
import BlogEditorView from '@/views/blog/BlogEditorView.vue';
|
||||
|
||||
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 }) },
|
||||
// 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 },
|
||||
];
|
||||
@@ -6,6 +6,7 @@ import socialRoutes from './socialRoutes';
|
||||
import settingsRoutes from './settingsRoutes';
|
||||
import adminRoutes from './adminRoutes';
|
||||
import falukantRoutes from './falukantRoutes';
|
||||
import blogRoutes from './blogRoutes';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -18,6 +19,7 @@ const routes = [
|
||||
...settingsRoutes,
|
||||
...adminRoutes,
|
||||
...falukantRoutes,
|
||||
...blogRoutes,
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
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>
|
||||
29
frontend/src/views/blog/BlogListView.vue
Normal file
29
frontend/src/views/blog/BlogListView.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="blog-list">
|
||||
<h1>Blogs</h1>
|
||||
<div class="toolbar">
|
||||
<router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link>
|
||||
</div>
|
||||
<div v-if="loading">Laden…</div>
|
||||
<div v-else>
|
||||
<div v-if="!blogs.length">Keine Blogs gefunden.</div>
|
||||
<ul>
|
||||
<li v-for="b in blogs" :key="b.id">
|
||||
<router-link :to="`/blogs/${b.id}`">{{ b.title }}</router-link>
|
||||
<small> – {{ b.owner?.username }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listBlogs } from '@/api/blogApi.js';
|
||||
export default {
|
||||
name: 'BlogListView',
|
||||
data: () => ({ blogs: [], loading: true }),
|
||||
async mounted() {
|
||||
try { this.blogs = await listBlogs(); } finally { this.loading = false; }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
109
frontend/src/views/blog/BlogView.vue
Normal file
109
frontend/src/views/blog/BlogView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="blog-view">
|
||||
<div v-if="loading">Laden…</div>
|
||||
<div v-else>
|
||||
<h1>{{ blog.title }}</h1>
|
||||
<p v-if="blog.description">{{ blog.description }}</p>
|
||||
<div class="meta">von {{ blog.owner?.username }}</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>
|
||||
<div class="posts">
|
||||
<h2>{{ $t('blog.posts') }}</h2>
|
||||
<div v-if="!items.length">{{ $t('blog.noPosts') }}</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>
|
||||
</div>
|
||||
<div v-if="isOwner" class="post-editor">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getBlog, listPosts, createPost } from '@/api/blogApi.js';
|
||||
import DOMPurify from 'dompurify';
|
||||
import RichTextEditor from './components/RichTextEditor.vue';
|
||||
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);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
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 || 'http://localhost:3001'}/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);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// this.$router.replace('/blogs');
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
methods: {
|
||||
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;
|
||||
},
|
||||
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;
|
||||
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>
|
||||
.editbutton {
|
||||
border: 1px solid #000;
|
||||
background-color: #f9a22c;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 3px;
|
||||
padding: 0.2em 0.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
124
frontend/src/views/blog/components/RichTextEditor.vue
Normal file
124
frontend/src/views/blog/components/RichTextEditor.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="rte">
|
||||
<div class="toolbar">
|
||||
<button type="button" @click="toggle('bold')"><b>B</b></button>
|
||||
<button type="button" @click="toggle('italic')"><i>I</i></button>
|
||||
<button type="button" @click="toggle('underline')"><u>U</u></button>
|
||||
<select v-model="heading" @change="applyHeading">
|
||||
<option :value="0">P</option>
|
||||
<option :value="1">H1</option>
|
||||
<option :value="2">H2</option>
|
||||
<option :value="3">H3</option>
|
||||
</select>
|
||||
<button type="button" @click="setAlign('left')">⟸</button>
|
||||
<button type="button" @click="setAlign('center')">⇔</button>
|
||||
<button type="button" @click="setAlign('right')">⟹</button>
|
||||
<input type="color" v-model="color" @input="setColor" />
|
||||
<button type="button" @click="openImagePicker">🖼️</button>
|
||||
<input ref="file" type="file" accept="image/*" class="hidden" @change="onUpload" />
|
||||
</div>
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
|
||||
<div v-if="showPicker" class="picker">
|
||||
<div class="picker-header">
|
||||
<span>{{ $t('blog.pickImage') }}</span>
|
||||
<button @click="showPicker=false">✕</button>
|
||||
</div>
|
||||
<div class="picker-actions">
|
||||
<button @click="triggerUpload">{{ $t('blog.uploadImage') }}</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="thumb" v-for="img in images" :key="img.id" @click="insertGallery(img)">
|
||||
<img :src="imageUrl(img)" :alt="img.title" />
|
||||
<div class="title">{{ img.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { EditorContent, Editor } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TextStyle from '@tiptap/extension-text-style';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { listBlogImages, uploadBlogImage } from '@/api/blogApi.js';
|
||||
|
||||
export default {
|
||||
name: 'RichTextEditor',
|
||||
components: { EditorContent },
|
||||
props: { modelValue: { type: String, default: '' }, blogId: { type: [String, Number], required: true } },
|
||||
emits: ['update:modelValue'],
|
||||
data: () => ({ editor: null, heading: 0, color: '#000000', showPicker: false, images: [] }),
|
||||
mounted() {
|
||||
this.editor = new Editor({
|
||||
extensions: [StarterKit, Underline, TextStyle, Color, Image, TextAlign.configure({ types: ['heading', 'paragraph'] })],
|
||||
content: this.modelValue || '',
|
||||
editable: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'pm-root'
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => this.$emit('update:modelValue', editor.getHTML())
|
||||
});
|
||||
},
|
||||
beforeUnmount() { this.editor?.destroy?.(); },
|
||||
methods: {
|
||||
toggle(cmd) { this.editor?.chain().focus()[cmd]().run(); },
|
||||
applyHeading() {
|
||||
const level = Number(this.heading);
|
||||
const chain = this.editor?.chain().focus();
|
||||
if (level === 0) { chain.setParagraph().run(); } else { chain.toggleHeading({ level }).run(); }
|
||||
},
|
||||
setAlign(a) { this.editor?.chain().focus().setTextAlign(a).run(); },
|
||||
setColor() { this.editor?.chain().focus().setColor(this.color).run(); },
|
||||
openImagePicker: async function() {
|
||||
this.showPicker = true;
|
||||
const res = await listBlogImages(this.blogId);
|
||||
this.images = res.images || [];
|
||||
},
|
||||
imageUrl(img) { return `/api/blog/blogs/images/${img.hash}`; },
|
||||
insertGallery(img) {
|
||||
this.editor?.chain().focus().setImage?.({ src: this.imageUrl(img), alt: img.title }).run?.();
|
||||
// fallback: insert simple <img>
|
||||
if (!this.editor?.isActive('image')) {
|
||||
const html = `<img src="${this.imageUrl(img)}" alt="${img.title || ''}">`;
|
||||
this.editor.commands.insertContent(html);
|
||||
}
|
||||
this.showPicker = false;
|
||||
},
|
||||
triggerUpload() { this.$refs.file.click(); },
|
||||
async onUpload(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const img = await uploadBlogImage(this.blogId, file, { title: file.name });
|
||||
this.images.unshift(img);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(v) { if (this.editor && v !== this.editor.getHTML()) this.editor.commands.setContent(v || '', false); }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rte { position: relative; }
|
||||
.toolbar { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; margin-bottom:.5rem; }
|
||||
.hidden{ display:none }
|
||||
.editor { border:1px solid #ccc; min-height:260px; padding:0; cursor:text; }
|
||||
:deep(.ProseMirror) { min-height: 260px; outline: none; padding:.5rem; box-sizing: border-box; width:100%; }
|
||||
:deep(.ProseMirror p) { margin: 0 0 .6rem; }
|
||||
:deep(.ProseMirror p:first-child) { margin-top: 0; }
|
||||
:deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); }
|
||||
.picker { position:absolute; top:2.5rem; left:0; right:0; background:#fff; border:1px solid #ccc; padding:.5rem; z-index:10; max-height:50vh; overflow:auto; }
|
||||
.picker-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:.5rem; }
|
||||
.picker-actions { margin-bottom:.5rem; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap:.5rem; }
|
||||
.thumb { border:1px solid #ddd; padding:.25rem; cursor:pointer; }
|
||||
.thumb img { width:100%; height:90px; object-fit:cover; display:block; }
|
||||
.thumb .title { font-size:.8rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
</style>
|
||||
@@ -14,7 +14,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="editor-container">
|
||||
<EditorContent :editor="editor" class="editor" />
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
</div>
|
||||
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
|
||||
</template>
|
||||
@@ -47,6 +47,8 @@ export default {
|
||||
this.editor = new Editor({
|
||||
extensions: [StarterKit],
|
||||
content: '',
|
||||
editable: true,
|
||||
editorProps: { attributes: { class: 'pm-root' } },
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -126,13 +128,24 @@ export default {
|
||||
.editor-container {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
min-height: 200px;
|
||||
padding: 0;
|
||||
min-height: 260px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 150px;
|
||||
min-height: 260px;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
}
|
||||
.editor :deep(.ProseMirror) {
|
||||
min-height: 260px;
|
||||
outline: none;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.editor :deep(.ProseMirror p) { margin: 0 0 .6rem; }
|
||||
.editor :deep(.ProseMirror p:first-child) { margin-top: 0; }
|
||||
.editor :deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); }
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<EditorContent :editor="editor" class="editor" />
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
</div>
|
||||
<button @click="saveNewTopic">
|
||||
{{ $t('socialnetwork.forum.createNewTopic') }}
|
||||
@@ -89,6 +89,8 @@ export default {
|
||||
this.editor = new Editor({
|
||||
extensions: [StarterKit],
|
||||
content: '',
|
||||
editable: true,
|
||||
editorProps: { attributes: { class: 'pm-root' } },
|
||||
})
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -111,6 +113,7 @@ export default {
|
||||
this.inCreation = !this.inCreation
|
||||
if (this.inCreation && this.editor) {
|
||||
this.editor.commands.setContent('')
|
||||
this.$nextTick(() => this.editor?.commands.focus('end'))
|
||||
}
|
||||
},
|
||||
async saveNewTopic() {
|
||||
@@ -167,14 +170,25 @@ export default {
|
||||
.editor-container {
|
||||
margin: 1em 0;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
min-height: 200px;
|
||||
padding: 0;
|
||||
min-height: 260px;
|
||||
background-color: white;
|
||||
}
|
||||
.editor {
|
||||
min-height: 150px;
|
||||
min-height: 260px;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
}
|
||||
.editor :deep(.ProseMirror) {
|
||||
min-height: 260px;
|
||||
outline: none;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.editor :deep(.ProseMirror p) { margin: 0 0 .6rem; }
|
||||
.editor :deep(.ProseMirror p:first-child) { margin-top: 0; }
|
||||
.editor :deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); }
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user