- 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.
125 lines
5.3 KiB
Vue
125 lines
5.3 KiB
Vue
<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>
|