Files
yourpart3/frontend/src/views/blog/components/RichTextEditor.vue
Torsten Schulz (local) 53c748a074 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.
2025-08-18 13:41:37 +02:00

125 lines
5.3 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>