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:
Torsten Schulz (local)
2025-08-18 13:41:37 +02:00
parent 19ee6ba0a1
commit 53c748a074
27 changed files with 1342 additions and 19 deletions

View 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>