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

@@ -11,6 +11,7 @@ import socialnetworkRouter from './routers/socialnetworkRouter.js';
import forumRouter from './routers/forumRouter.js'; import forumRouter from './routers/forumRouter.js';
import falukantRouter from './routers/falukantRouter.js'; import falukantRouter from './routers/falukantRouter.js';
import friendshipRouter from './routers/friendshipRouter.js'; import friendshipRouter from './routers/friendshipRouter.js';
import blogRouter from './routers/blogRouter.js';
import cors from 'cors'; import cors from 'cors';
import './jobs/sessionCleanup.js'; import './jobs/sessionCleanup.js';
@@ -40,9 +41,16 @@ app.use('/api/socialnetwork', socialnetworkRouter);
app.use('/api/forum', forumRouter); app.use('/api/forum', forumRouter);
app.use('/api/falukant', falukantRouter); app.use('/api/falukant', falukantRouter);
app.use('/api/friendships', friendshipRouter); app.use('/api/friendships', friendshipRouter);
app.use('/api/blog', blogRouter);
app.use((req, res) => { // Serve frontend SPA for non-API routes to support history mode clean URLs
res.status(404).send('404 Not Found'); const frontendDir = path.join(__dirname, '../frontend');
app.use(express.static(path.join(frontendDir, 'dist')));
app.get(/^\/(?!api\/).*/, (req, res) => {
res.sendFile(path.join(frontendDir, 'dist', 'index.html'));
}); });
// Fallback 404 for unknown API routes
app.use('/api/*', (req, res) => res.status(404).send('404 Not Found'));
export default app; export default app;

View File

@@ -0,0 +1,174 @@
import BlogService from '../services/blogService.js';
import SocialNetworkService from '../services/socialnetworkService.js';
const blogService = new BlogService();
const socialService = new SocialNetworkService();
export const createBlog = async (req, res) => {
try {
const { userid } = req.headers; // creator must be logged in
const blog = await blogService.createBlog(userid, req.body || {});
res.json(blog);
} catch (e) {
res.status(e.status || 400).json({ error: e.message });
}
};
export const updateBlog = async (req, res) => {
try {
const { userid } = req.headers;
const { id } = req.params;
const blog = await blogService.updateBlog(userid, id, req.body || {});
res.json(blog);
} catch (e) {
res.status(e.status || 400).json({ error: e.message });
}
};
export const listBlogs = async (req, res) => {
try {
const { userid } = req.headers; // optional for access checks
const blogs = await blogService.listBlogsForViewer(userid || null);
res.json(blogs);
} catch (e) {
res.status(400).json({ error: e.message });
}
};
export const getBlog = async (req, res) => {
try {
const { userid } = req.headers;
const { id } = req.params;
const blog = await blogService.getBlog(id, userid || null);
res.json(blog);
} catch (e) {
res.status(e.status || 404).json({ error: e.message });
}
};
export const createPost = async (req, res) => {
try {
const { userid } = req.headers;
const { id } = req.params;
const post = await blogService.createPost(userid, id, req.body || {});
res.json(post);
} catch (e) {
res.status(e.status || 400).json({ error: e.message });
}
};
export const listPosts = async (req, res) => {
try {
const { userid } = req.headers;
const { id } = req.params;
const page = Math.max(1, parseInt(req.query.page || '1', 10));
const pageSize = Math.min(50, Math.max(1, parseInt(req.query.pageSize || '10', 10)));
const all = await blogService.listPosts(id, userid || null);
const total = all.length;
const start = (page - 1) * pageSize;
const posts = all.slice(start, start + pageSize);
res.json({ items: posts, page, pageSize, total });
} catch (e) {
res.status(e.status || 400).json({ error: e.message });
}
};
export const listBlogImages = async (req, res) => {
try {
const { userid } = req.headers;
const user = await blogService.getUserByHashedId(userid);
const blogFolder = await blogService.ensureBlogFolder(user.id);
const images = await socialService.getFolderImageList(userid, blogFolder.id);
res.json({ folderId: blogFolder.id, images });
} catch (e) {
res.status(400).json({ error: e.message });
}
};
export const uploadBlogImage = async (req, res) => {
try {
const { userid } = req.headers;
const user = await blogService.getUserByHashedId(userid);
const blogFolder = await blogService.ensureBlogFolder(user.id);
// Default visibility mirrors blog folder visibilities (already applied)
const formData = {
title: req.body?.title || 'Blog Image',
description: req.body?.description || '',
folderId: blogFolder.id,
visibility: JSON.stringify([])
};
const file = req.file;
const image = await socialService.uploadImage(userid, file, formData);
res.status(201).json(image);
} catch (e) {
res.status(400).json({ error: e.message });
}
};
export const getPublicBlogImageByHash = async (req, res) => {
try {
const { hash } = req.params;
const filePath = await socialService.getImageFilePathPublicByHash(hash);
res.sendFile(filePath, err => {
if (err) {
console.error('Error sending file:', err);
res.status(500).json({ error: 'Error sending file' });
}
});
} catch (e) {
res.status(e.status || 403).json({ error: e.message || 'Access denied or image not found' });
}
};
export const shareBlog = async (req, res) => {
try {
const { userid } = req.headers;
const { id } = req.params;
const { toFriends = false, emails = [] } = req.body || {};
// Prefer configured frontend base for public links; fallback to request origin
const feBase = process.env.FRONTEND_BASE_URL;
const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
const host = (req.headers['x-forwarded-host'] || req.get('host'));
const origin = (feBase && feBase.trim()) ? feBase.replace(/\/$/, '') : `${proto}://${host}`;
// Compute slug from username + title (no spaces, remove unsafe chars)
const { user, blog } = await blogService.assertOwner(userid, id);
const titleNoSpaces = (blog.title || '').toString().replace(/\s+/g, '');
const base = `${user.username || 'user'}${titleNoSpaces}`;
const slug = base.replace(/[^a-zA-Z0-9_-]/g, '');
// Pretty public URL under frontend: /blogs/:slug
const url = `${origin}/blogs/${encodeURIComponent(slug)}`;
const result = await blogService.shareBlog(userid, id, { toFriends, emails }, url);
res.json(result);
} catch (e) {
res.status(e.status || 400).json({ error: e.message });
}
};
export const resolveBlogSlug = async (req, res) => {
try {
const { slug } = req.params;
const blogId = await blogService.resolveSlug(slug);
if (!blogId) return res.status(404).json({ error: 'Not found' });
// Redirect to SPA history route so the client router loads the blog
const feBase = process.env.FRONTEND_BASE_URL;
const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
const host = (req.headers['x-forwarded-host'] || req.get('host'));
const origin = (feBase && feBase.trim()) ? feBase.replace(/\/$/, '') : `${proto}://${host}`;
return res.redirect(302, `${origin}/blogs/${blogId}/${encodeURIComponent(slug)}`);
} catch (e) {
res.status(400).json({ error: e.message });
}
};
export const resolveBlogSlugId = async (req, res) => {
try {
const { slug } = req.params;
const blogId = await blogService.resolveSlug(slug);
if (!blogId) return res.status(404).json({ error: 'Not found' });
res.json({ id: blogId });
} catch (e) {
res.status(400).json({ error: e.message });
}
};

View File

@@ -32,6 +32,10 @@ const menuStructure = {
visible: ["all"], visible: ["all"],
path: "/socialnetwork/guestbook" path: "/socialnetwork/guestbook"
}, },
blog: {
visible: ["all"],
path: "/blogs"
},
usersearch: { usersearch: {
visible: ["all"], visible: ["all"],
path: "/socialnetwork/search" path: "/socialnetwork/search"

View File

@@ -95,6 +95,8 @@ import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
import ElectionHistory from './falukant/log/election_history.js'; import ElectionHistory from './falukant/log/election_history.js';
import Underground from './falukant/data/underground.js'; import Underground from './falukant/data/underground.js';
import UndergroundType from './falukant/type/underground.js'; import UndergroundType from './falukant/type/underground.js';
import Blog from './community/blog.js';
import BlogPost from './community/blog_post.js';
export default function setupAssociations() { export default function setupAssociations() {
// RoomType 1:n Room // RoomType 1:n Room
@@ -758,4 +760,12 @@ export default function setupAssociations() {
foreignKey: 'victimId', foreignKey: 'victimId',
as: 'victimUndergrounds' as: 'victimUndergrounds'
}); });
// Blog associations
Blog.belongsTo(User, { foreignKey: 'user_id', as: 'owner' });
User.hasMany(Blog, { foreignKey: 'user_id', as: 'blogs' });
BlogPost.belongsTo(Blog, { foreignKey: 'blog_id', as: 'blog' });
Blog.hasMany(BlogPost, { foreignKey: 'blog_id', as: 'posts' });
BlogPost.belongsTo(User, { foreignKey: 'user_id', as: 'author' });
User.hasMany(BlogPost, { foreignKey: 'user_id', as: 'blogPosts' });
} }

View File

@@ -0,0 +1,60 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../utils/sequelize.js';
class Blog extends Model {}
Blog.init({
userId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'user_id'
},
title: {
type: DataTypes.STRING(255),
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
// 'public' or 'logged_in'
visibility: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'public',
},
ageMin: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'age_min'
},
ageMax: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'age_max'
},
// 'm' | 'f' | null; comma-separated for future-proofing (e.g., 'm,f')
genders: {
type: DataTypes.STRING(10),
allowNull: true,
},
createdAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
field: 'created_at'
},
updatedAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
field: 'updated_at'
}
}, {
sequelize,
modelName: 'Blog',
tableName: 'blog',
schema: 'community',
timestamps: true,
underscored: true,
});
export default Blog;

View File

@@ -0,0 +1,44 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../utils/sequelize.js';
class BlogPost extends Model {}
BlogPost.init({
blogId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'blog_id'
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'user_id'
},
title: {
type: DataTypes.STRING(255),
allowNull: false,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
},
createdAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
field: 'created_at'
},
updatedAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
field: 'updated_at'
}
}, {
sequelize,
modelName: 'BlogPost',
tableName: 'blog_post',
schema: 'community',
timestamps: true,
underscored: true,
});
export default BlogPost;

View File

@@ -34,6 +34,8 @@ import MessageHistory from './forum/message_history.js';
import MessageImage from './forum/message_image.js'; import MessageImage from './forum/message_image.js';
import ForumForumPermission from './forum/forum_forum_permission.js'; import ForumForumPermission from './forum/forum_forum_permission.js';
import Friendship from './community/friendship.js'; import Friendship from './community/friendship.js';
import Blog from './community/blog.js';
import BlogPost from './community/blog_post.js';
import FalukantUser from './falukant/data/user.js'; import FalukantUser from './falukant/data/user.js';
import RegionType from './falukant/type/region.js'; import RegionType from './falukant/type/region.js';
@@ -140,6 +142,8 @@ const models = {
MessageHistory, MessageHistory,
MessageImage, MessageImage,
Friendship, Friendship,
Blog,
BlogPost,
// Falukant core // Falukant core
RegionType, RegionType,

View File

@@ -0,0 +1,32 @@
import { Router } from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import multer from 'multer';
import { createBlog, updateBlog, listBlogs, getBlog, createPost, listPosts, listBlogImages, uploadBlogImage, getPublicBlogImageByHash, shareBlog, resolveBlogSlug, resolveBlogSlugId } from '../controllers/blogController.js';
const upload = multer();
const router = Router();
// Public list (filtered by visibility); if logged in, pass userid in headers for access
router.get('/blogs', listBlogs);
// Public get (access enforced per blog)
router.get('/blogs/:id', getBlog);
// Public list posts (access enforced per blog)
router.get('/blogs/:id/posts', listPosts); // supports ?page=&pageSize=
// Public serve image by hash if folder has 'everyone' visibility
router.get('/blogs/images/:hash', getPublicBlogImageByHash);
// Public: resolve pretty slug to SPA route and JSON id
router.get('/blogs/slug/:slug', resolveBlogSlug);
router.get('/blogs/slug/:slug/id', resolveBlogSlugId);
// Create/Update require login (any logged-in user can create/edit their own blog); posts require login
router.post('/blogs', authenticate, createBlog);
router.put('/blogs/:id', authenticate, updateBlog);
router.post('/blogs/:id/posts', authenticate, createPost);
router.post('/blogs/:id/share', authenticate, shareBlog);
// Blog images: list and upload to user's Blog folder
router.get('/blogs/:id/images', authenticate, listBlogImages);
router.post('/blogs/:id/images', authenticate, upload.single('image'), uploadBlogImage);
export default router;

View File

@@ -0,0 +1,246 @@
import BaseService from './BaseService.js';
import Blog from '../models/community/blog.js';
import BlogPost from '../models/community/blog_post.js';
import User from '../models/community/user.js';
import UserParam from '../models/community/user_param.js';
import UserParamType from '../models/type/user_param.js';
import UserParamValue from '../models/type/user_param_value.js';
import { Op } from 'sequelize';
import Folder from '../models/community/folder.js';
import Image from '../models/community/image.js';
import ImageVisibilityType from '../models/type/image_visibility.js';
import FolderImageVisibility from '../models/community/folder_image_visibility.js';
import friendshipService from './friendshipService.js';
import { notifyUser } from '../utils/socket.js';
import { sendBlogShareEmail } from './emailService.js';
import { sequelize } from '../utils/sequelize.js';
export default class BlogService extends BaseService {
async createBlog(hashedUserId, { title, description, visibility, ageMin, ageMax, genders }) {
const user = await this.getUserByHashedId(hashedUserId);
const blog = await Blog.create({
userId: user.id,
title,
description: description || null,
visibility: visibility || 'public',
ageMin: ageMin ?? null,
ageMax: ageMax ?? null,
genders: Array.isArray(genders) ? genders.join(',') : (genders || null),
});
await this.ensureBlogFolder(user.id);
await this.applyBlogFolderVisibility(user.id, blog.visibility);
return blog;
}
async assertOwner(hashedUserId, blogId) {
const user = await this.getUserByHashedId(hashedUserId);
const blog = await Blog.findByPk(blogId);
if (!blog) {
const err = new Error('Blog not found');
err.status = 404;
throw err;
}
if (blog.userId !== user.id) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
return { user, blog };
}
async shareBlog(hashedUserId, blogId, { toFriends = false, emails = [] } = {}, url) {
const { user, blog } = await this.assertOwner(hashedUserId, blogId);
// Notify friends (accepted)
let notifiedFriends = 0;
if (toFriends) {
try {
const friends = await friendshipService.getFriendships(hashedUserId, true);
for (const f of friends) {
if (f.accepted && f.user?.hashedId) {
notifiedFriends++;
notifyUser(f.user.hashedId, 'blogShared', {
blogId: blogId,
title: blog.title,
url,
by: user.username,
});
}
}
} catch (e) {
// Continue even if notifications fail
console.error('Error notifying friends for blog share:', e);
}
}
// Send emails
let emailsSent = 0;
if (Array.isArray(emails) && emails.length) {
const uniqueValid = Array.from(new Set(
emails
.map(e => (typeof e === 'string' ? e.trim() : ''))
.filter(e => e && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))
));
for (const to of uniqueValid) {
try {
await sendBlogShareEmail(to, url, blog.title, user.username);
emailsSent++;
} catch (e) {
console.error('Error sending blog share email to', to, e);
}
}
}
return { url, notifiedFriends, emailsSent };
}
async resolveSlug(slug) {
// Try username+title match first (legacy), then title-only match.
const sqlConcat = `
SELECT b.id
FROM community.blog b
JOIN community.user u ON u.id = b.user_id
WHERE LOWER(
regexp_replace(u.username, '[^a-zA-Z0-9_-]', '', 'g') ||
regexp_replace(regexp_replace(b.title, '\\s+', '', 'g'), '[^a-zA-Z0-9_-]', '', 'g')
) = LOWER(:slug)
LIMIT 1
`;
const [concatRows] = await sequelize.query(sqlConcat, { replacements: { slug } });
if (concatRows && concatRows.length) return concatRows[0].id;
const sqlTitle = `
SELECT b.id
FROM community.blog b
WHERE LOWER(
regexp_replace(regexp_replace(b.title, '\\s+', '', 'g'), '[^a-zA-Z0-9_-]', '', 'g')
) = LOWER(:slug)
ORDER BY b.updated_at DESC
LIMIT 1
`;
const [titleRows] = await sequelize.query(sqlTitle, { replacements: { slug } });
if (titleRows && titleRows.length) return titleRows[0].id;
return null;
}
async updateBlog(hashedUserId, blogId, data) {
const user = await this.getUserByHashedId(hashedUserId);
const blog = await Blog.findOne({ where: { id: blogId, userId: user.id } });
if (!blog) throw new Error('Blog not found or access denied');
const patch = { ...data };
if (Array.isArray(patch.genders)) patch.genders = patch.genders.join(',');
const prevVisibility = blog.visibility;
await blog.update(patch);
if (patch.visibility && patch.visibility !== prevVisibility) {
await this.applyBlogFolderVisibility(user.id, blog.visibility);
}
return blog;
}
async listBlogsForViewer(viewerHashedId = null) {
// Public blogs are always visible. Logged-in can also see logged_in, plus filter by age/gender if provided (per blog when set).
let viewer = null, age = null, gender = null;
if (viewerHashedId) {
try { viewer = await this.getUserByHashedId(viewerHashedId); } catch { viewer = null; }
if (viewer) {
const params = await this.getUserParams(viewer.id, ['birthdate', 'gender']);
for (const p of params) {
if (p.paramType.description === 'birthdate') age = this.calculateAge(p.value);
if (p.paramType.description === 'gender') {
const gv = await UserParamValue.findOne({ where: { id: p.value } });
gender = gv ? gv.value : null;
}
}
}
}
const where = viewer ? { } : { visibility: 'public' };
const blogs = await Blog.findAll({ where, include: [{ model: User, as: 'owner', attributes: ['username', 'hashedId'] }] });
return blogs.filter(b => this.checkBlogAccess(b, !!viewer, age, gender));
}
checkBlogAccess(blog, isLoggedIn, age, gender) {
if (blog.visibility === 'public') return true;
if (!isLoggedIn) return false;
// age range check if provided
if (blog.ageMin != null && (age == null || age < blog.ageMin)) return false;
if (blog.ageMax != null && (age == null || age > blog.ageMax)) return false;
// gender check if provided
if (blog.genders) {
const allowed = blog.genders.split(',').map(s => s.trim()).filter(Boolean);
if (allowed.length && (!gender || !allowed.includes(gender))) return false;
}
return true;
}
async getBlog(blogId, viewerHashedId = null) {
const blog = await Blog.findByPk(blogId, { include: [{ model: User, as: 'owner', attributes: ['username', 'hashedId'] }] });
if (!blog) throw new Error('Blog not found');
let age = null, gender = null, logged = false;
if (viewerHashedId) {
try {
const viewer = await this.getUserByHashedId(viewerHashedId);
logged = !!viewer;
if (viewer) {
const params = await this.getUserParams(viewer.id, ['birthdate', 'gender']);
for (const p of params) {
if (p.paramType.description === 'birthdate') age = this.calculateAge(p.value);
if (p.paramType.description === 'gender') {
const gv = await UserParamValue.findOne({ where: { id: p.value } });
gender = gv ? gv.value : null;
}
}
}
} catch {}
}
if (!this.checkBlogAccess(blog, logged, age, gender)) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
return blog;
}
async createPost(hashedUserId, blogId, { title, content }) {
const user = await this.getUserByHashedId(hashedUserId);
const blog = await Blog.findOne({ where: { id: blogId } });
if (!blog) throw new Error('Blog not found');
// Only logged-in users; editor access: any logged-in (as requested)
return await BlogPost.create({ blogId: blog.id, userId: user.id, title, content });
}
async listPosts(blogId, viewerHashedId = null) {
// Enforce blog visibility before listing
await this.getBlog(blogId, viewerHashedId);
// pagination support via query params will be handled in controller; keep a method variant with args
return await BlogPost.findAll({ where: { blogId }, order: [['created_at', 'desc']] });
}
// Utilities for Blog image folder
async ensureBlogFolder(userId) {
// find root folder
let root = await Folder.findOne({ where: { parentId: null, userId } });
if (!root) {
// fallback: create minimal root with default everyone visibility
root = await Folder.create({ name: 'root', parentId: null, userId });
const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } });
if (everyone) await FolderImageVisibility.create({ folderId: root.id, visibilityTypeId: everyone.id });
}
let blogFolder = await Folder.findOne({ where: { parentId: root.id, userId, name: 'Blog' } });
if (!blogFolder) {
blogFolder = await Folder.create({ name: 'Blog', parentId: root.id, userId });
}
return blogFolder;
}
async applyBlogFolderVisibility(userId, blogVisibility) {
const blogFolder = await this.ensureBlogFolder(userId);
const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } });
if (!everyone) return;
// Clear existing visibilities for the Blog folder
await FolderImageVisibility.destroy({ where: { folderId: blogFolder.id } });
if (blogVisibility === 'public') {
await FolderImageVisibility.create({ folderId: blogFolder.id, visibilityTypeId: everyone.id });
}
// For non-public blogs we leave folder without 'everyone' (private by default)
return blogFolder;
}
}

View File

@@ -55,3 +55,17 @@ export const sendAnswerEmail = async (toEmail, answer, language) => {
await transporter.sendMail(mailOptions); await transporter.sendMail(mailOptions);
}; };
export const sendBlogShareEmail = async (toEmail, blogUrl, blogTitle, senderName) => {
const subject = `yourPart: ${senderName} hat einen Blog geteilt`;
const text = `${senderName} hat den Blog "${blogTitle}" mit dir geteilt. Du kannst ihn hier ansehen: ${blogUrl}`;
const html = `<p><strong>${senderName}</strong> hat den Blog "${blogTitle}" mit dir geteilt.</p><p>Hier ansehen: <a href="${blogUrl}">${blogUrl}</a></p>`;
const mailOptions = {
from: process.env.SMTP_FROM,
to: toEmail,
subject,
text,
html
};
await transporter.sendMail(mailOptions);
};

View File

@@ -467,6 +467,33 @@ class SocialNetworkService extends BaseService {
return imagePath; return imagePath;
} }
// Public variant used by blog: allow access if the image's folder is visible to 'everyone'.
async getImageFilePathPublicByHash(hash) {
const image = await Image.findOne({ where: { hash } });
if (!image) {
throw new Error('Image not found');
}
const folder = await Folder.findOne({ where: { id: image.folderId } });
if (!folder) {
throw new Error('Folder not found');
}
const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } });
if (!everyone) {
throw new Error('Visibility type not found');
}
const hasEveryone = await FolderImageVisibility.findOne({ where: { folderId: folder.id, visibilityTypeId: everyone.id } });
if (!hasEveryone) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const imagePath = this.buildFilePath(image.hash, 'user');
if (!fs.existsSync(imagePath)) {
throw new Error(`File "${imagePath}" not found`);
}
return imagePath;
}
async checkUserImageAccess(userId, imageId) { async checkUserImageAccess(userId, imageId) {
const image = await Image.findByPk(imageId); const image = await Image.findByPk(imageId);
if (image.userId === userId) { if (image.userId === userId) {

View File

@@ -8,10 +8,16 @@
"name": "frontend", "name": "frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "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/starter-kit": "^2.14.0",
"@tiptap/vue-3": "^2.14.0", "@tiptap/vue-3": "^2.14.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dompurify": "^3.2.6",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
@@ -969,6 +975,20 @@
"@tiptap/pm": "^2.7.0" "@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": { "node_modules/@tiptap/extension-document": {
"version": "2.14.0", "version": "2.14.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.14.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.14.0.tgz",
@@ -1081,6 +1101,19 @@
"@tiptap/pm": "^2.7.0" "@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": { "node_modules/@tiptap/extension-italic": {
"version": "2.14.0", "version": "2.14.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.14.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.14.0.tgz",
@@ -1159,6 +1192,19 @@
"@tiptap/core": "^2.7.0" "@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": { "node_modules/@tiptap/extension-text-style": {
"version": "2.14.0", "version": "2.14.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.14.0.tgz", "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" "@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": { "node_modules/@tiptap/pm": {
"version": "2.14.0", "version": "2.14.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.14.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.14.0.tgz",
@@ -1283,6 +1342,13 @@
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT" "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": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1612,6 +1678,15 @@
"node": ">=0.4.0" "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": { "node_modules/dotenv": {
"version": "16.4.5", "version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",

View File

@@ -8,10 +8,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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/starter-kit": "^2.14.0",
"@tiptap/vue-3": "^2.14.0", "@tiptap/vue-3": "^2.14.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dompurify": "^3.2.6",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",

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

View File

@@ -15,6 +15,7 @@ import enSocialNetwork from './locales/en/socialnetwork.json';
import enFriends from './locales/en/friends.json'; import enFriends from './locales/en/friends.json';
import enFalukant from './locales/en/falukant.json'; import enFalukant from './locales/en/falukant.json';
import enPasswordReset from './locales/en/passwordReset.json'; import enPasswordReset from './locales/en/passwordReset.json';
import enBlog from './locales/en/blog.json';
import deGeneral from './locales/de/general.json'; import deGeneral from './locales/de/general.json';
import deHeader from './locales/de/header.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 deFriends from './locales/de/friends.json';
import deFalukant from './locales/de/falukant.json'; import deFalukant from './locales/de/falukant.json';
import dePasswordReset from './locales/de/passwordReset.json'; import dePasswordReset from './locales/de/passwordReset.json';
import deBlog from './locales/de/blog.json';
const messages = { const messages = {
en: { en: {
@@ -47,6 +49,7 @@ const messages = {
...enSocialNetwork, ...enSocialNetwork,
...enFriends, ...enFriends,
...enFalukant, ...enFalukant,
...enBlog,
}, },
de: { de: {
'Ok': 'Ok', 'Ok': 'Ok',
@@ -64,6 +67,7 @@ const messages = {
...deSocialNetwork, ...deSocialNetwork,
...deFriends, ...deFriends,
...deFalukant, ...deFalukant,
...deBlog,
} }
}; };

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

View File

@@ -16,6 +16,7 @@
}, },
"m-socialnetwork": { "m-socialnetwork": {
"guestbook": "Gästebuch", "guestbook": "Gästebuch",
"blog": "Blog",
"usersearch": "Benutzersuche", "usersearch": "Benutzersuche",
"forum": "Forum", "forum": "Forum",
"gallery": "Galerie", "gallery": "Galerie",

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

View File

@@ -1,11 +1,79 @@
{ {
"home": "Home", "navigation": {
"about": "About", "home": "Home",
"services": "Services", "logout": "Logout",
"team": "Team", "friends": "Friends",
"company": "Company", "socialnetwork": "Meeting point",
"consulting": "Consulting", "chats": "Chats",
"development": "Development", "falukant": "Falukant",
"mailbox": "Mailbox", "minigames": "Mini games",
"logout": "Logout" "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"
}
}
} }

View 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 },
];

View File

@@ -6,6 +6,7 @@ import socialRoutes from './socialRoutes';
import settingsRoutes from './settingsRoutes'; import settingsRoutes from './settingsRoutes';
import adminRoutes from './adminRoutes'; import adminRoutes from './adminRoutes';
import falukantRoutes from './falukantRoutes'; import falukantRoutes from './falukantRoutes';
import blogRoutes from './blogRoutes';
const routes = [ const routes = [
{ {
@@ -18,6 +19,7 @@ const routes = [
...settingsRoutes, ...settingsRoutes,
...adminRoutes, ...adminRoutes,
...falukantRoutes, ...falukantRoutes,
...blogRoutes,
]; ];
const router = createRouter({ const router = createRouter({

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

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

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

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>

View File

@@ -14,7 +14,7 @@
</ul> </ul>
<div class="editor-container"> <div class="editor-container">
<EditorContent :editor="editor" class="editor" /> <EditorContent v-if="editor" :editor="editor" class="editor" />
</div> </div>
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button> <button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
</template> </template>
@@ -47,6 +47,8 @@ export default {
this.editor = new Editor({ this.editor = new Editor({
extensions: [StarterKit], extensions: [StarterKit],
content: '', content: '',
editable: true,
editorProps: { attributes: { class: 'pm-root' } },
}); });
}, },
beforeUnmount() { beforeUnmount() {
@@ -126,13 +128,24 @@ export default {
.editor-container { .editor-container {
margin-top: 1rem; margin-top: 1rem;
border: 1px solid #ccc; border: 1px solid #ccc;
padding: 10px; padding: 0;
min-height: 200px; min-height: 260px;
background-color: white; background-color: white;
} }
.editor { .editor {
min-height: 150px; min-height: 260px;
outline: none; 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> </style>

View File

@@ -16,7 +16,7 @@
</label> </label>
</div> </div>
<div class="editor-container"> <div class="editor-container">
<EditorContent :editor="editor" class="editor" /> <EditorContent v-if="editor" :editor="editor" class="editor" />
</div> </div>
<button @click="saveNewTopic"> <button @click="saveNewTopic">
{{ $t('socialnetwork.forum.createNewTopic') }} {{ $t('socialnetwork.forum.createNewTopic') }}
@@ -89,6 +89,8 @@ export default {
this.editor = new Editor({ this.editor = new Editor({
extensions: [StarterKit], extensions: [StarterKit],
content: '', content: '',
editable: true,
editorProps: { attributes: { class: 'pm-root' } },
}) })
}, },
beforeUnmount() { beforeUnmount() {
@@ -111,6 +113,7 @@ export default {
this.inCreation = !this.inCreation this.inCreation = !this.inCreation
if (this.inCreation && this.editor) { if (this.inCreation && this.editor) {
this.editor.commands.setContent('') this.editor.commands.setContent('')
this.$nextTick(() => this.editor?.commands.focus('end'))
} }
}, },
async saveNewTopic() { async saveNewTopic() {
@@ -167,14 +170,25 @@ export default {
.editor-container { .editor-container {
margin: 1em 0; margin: 1em 0;
border: 1px solid #ccc; border: 1px solid #ccc;
padding: 10px; padding: 0;
min-height: 200px; min-height: 260px;
background-color: white; background-color: white;
} }
.editor { .editor {
min-height: 150px; min-height: 260px;
outline: none; 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 { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;