feat(Moderation): enhance moderation reporting and user feedback
All checks were successful
Deploy to production / deploy (push) Successful in 1m55s

- Added user blocking checks in authentication and reporting processes, returning appropriate error responses.
- Expanded moderation report functionality to include new target types and optional fields for reports.
- Implemented a new API endpoint to retrieve the count of open moderation reports.
- Enhanced frontend components to allow users to report profiles, images, and guestbook entries, with corresponding UI updates.
- Updated internationalization files to include new strings for reporting features in both German and English.
This commit is contained in:
Torsten Schulz (local)
2026-04-27 15:57:02 +02:00
parent e94ae4350d
commit 530855e26e
16 changed files with 417 additions and 20 deletions

View File

@@ -48,7 +48,17 @@
:style="`background-image:url('/images/icons/${subitem.icon}')`"
class="submenu-icon"
>&nbsp;</span>
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span class="submenu-label-row">
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span
v-if="key === 'administration' && subkey === 'moderationReports' && moderationOpenCount > 0"
class="moderation-alert-badge"
:title="$t('navigation.m-administration.moderationBadgeTitle')"
>
<span class="moderation-alert-icon"></span>
<span class="moderation-alert-count">{{ moderationOpenCount }}</span>
</span>
</span>
<span v-if="subitem.disabled" class="menu-lock-badge">18+</span>
<span
v-if="hasSecondLevelSubmenu(subitem, subkey)"
@@ -203,7 +213,10 @@ export default {
_forumsChangedHandler: null,
_friendLoginChangedHandler: null,
_reloadMenuHandler: null,
_adultVerificationChangedHandler: null
_adultVerificationChangedHandler: null,
_moderationReportChangedHandler: null,
_userAccessChangedHandler: null,
moderationOpenCount: 0
};
},
computed: {
@@ -213,6 +226,12 @@ export default {
menuNeedsUpdate(newVal) {
if (newVal) this.loadMenu();
},
menu: {
deep: true,
handler() {
this.fetchModerationOpenCount();
}
},
$route() {
this.collapseMenus();
},
@@ -229,6 +248,7 @@ export default {
this.fetchForums();
this.fetchFriends();
this.fetchVocabLanguages();
this.fetchModerationOpenCount();
}
this.updateViewportState();
window.addEventListener('resize', this.updateViewportState);
@@ -263,10 +283,25 @@ export default {
showInfo(this, this.$t('socialnetwork.erotic.notifications.rejected'));
}
};
this._moderationReportChangedHandler = async (payload = {}) => {
if (Number.isFinite(Number(payload.openCount))) {
this.moderationOpenCount = Number(payload.openCount);
return;
}
await this.fetchModerationOpenCount();
};
this._userAccessChangedHandler = async (payload = {}) => {
if (payload.active === false) {
this.$root?.$refs?.messageDialog?.open?.('Dein Account wurde gesperrt.');
await this.logout();
}
};
sock.on('forumschanged', this._forumsChangedHandler);
sock.on('friendloginchanged', this._friendLoginChangedHandler);
sock.on('reloadmenu', this._reloadMenuHandler);
sock.on('adultVerificationChanged', this._adultVerificationChangedHandler);
sock.on('moderationReportChanged', this._moderationReportChangedHandler);
sock.on('userAccessChanged', this._userAccessChangedHandler);
},
unregisterSocketListeners() {
@@ -276,10 +311,14 @@ export default {
if (this._friendLoginChangedHandler) sock.off('friendloginchanged', this._friendLoginChangedHandler);
if (this._reloadMenuHandler) sock.off('reloadmenu', this._reloadMenuHandler);
if (this._adultVerificationChangedHandler) sock.off('adultVerificationChanged', this._adultVerificationChangedHandler);
if (this._moderationReportChangedHandler) sock.off('moderationReportChanged', this._moderationReportChangedHandler);
if (this._userAccessChangedHandler) sock.off('userAccessChanged', this._userAccessChangedHandler);
this._forumsChangedHandler = null;
this._friendLoginChangedHandler = null;
this._reloadMenuHandler = null;
this._adultVerificationChangedHandler = null;
this._moderationReportChangedHandler = null;
this._userAccessChangedHandler = null;
},
updateViewportState() {
@@ -461,6 +500,19 @@ export default {
this.vocabLanguagesList = [];
}
},
async fetchModerationOpenCount() {
try {
const adminChildren = this.menu?.administration?.children || {};
if (!adminChildren.moderationReports) {
this.moderationOpenCount = 0;
return;
}
const res = await apiClient.get('/api/admin/moderation/reports/open-count');
this.moderationOpenCount = Number(res?.data?.openCount || 0);
} catch (_) {
this.moderationOpenCount = 0;
}
},
openForum(forumId) {
this.$router.push({ name: 'Forum', params: { id: forumId } });
@@ -800,6 +852,29 @@ a {
border-radius: 14px;
}
.submenu-label-row {
display: inline-flex;
align-items: center;
gap: 8px;
}
.moderation-alert-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: #d32f2f;
color: #fff;
border-radius: 999px;
padding: 2px 7px;
font-weight: 800;
font-size: 0.72rem;
line-height: 1;
}
.moderation-alert-icon {
font-size: 0.78rem;
}
.submenu1 > li:hover {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.12);

View File

@@ -17,6 +17,11 @@
</li>
</ul>
<div class="tab-content" v-if="activeTab === 'general'">
<div class="profile-report-row">
<button type="button" class="report-btn" @click="reportProfile">
{{ $t('socialnetwork.profile.reportProfile') }}
</button>
</div>
<table>
<tr v-for="(value, key) in userProfile.params" :key="key">
<td>{{ $t(`socialnetwork.profile.${key}`) }}</td>
@@ -44,6 +49,9 @@
<li v-for="image in images" :key="image.id" @click="openImageDialog(image)">
<img :src="image.url || image.placeholder" :alt="$t('socialnetwork.gallery.imageLoadingAlt')" />
<p>{{ image.title }}</p>
<button type="button" class="report-btn" @click.stop="reportGalleryImage(image)">
{{ $t('socialnetwork.gallery.reportImage') }}
</button>
</li>
</ul>
</div>
@@ -82,6 +90,19 @@
<span @click="openProfile(entry.senderUsername)">{{ entry.sender }}</span>
</span>
</div>
<div class="entry-actions">
<button type="button" class="report-btn" @click="reportGuestbookEntry(entry)">
{{ $t('socialnetwork.profile.guestbook.reportEntry') }}
</button>
<button
v-if="canDeleteGuestbookEntry(entry)"
type="button"
class="delete-btn"
@click="deleteGuestbookEntry(entry)"
>
{{ $t('socialnetwork.profile.guestbook.deleteEntry') }}
</button>
</div>
</div>
<div class="pagination">
<button @click="loadGuestbookEntries(currentPage - 1)" :disabled="currentPage === 1">{{
@@ -357,6 +378,75 @@ export default {
console.error('Error fetching image:', error);
}
},
canDeleteGuestbookEntry(entry) {
const ownUsername = String(this.user?.username || '').toLowerCase();
const profileUsername = String(this.userProfile?.username || '').toLowerCase();
const sender = String(entry?.sender || '').toLowerCase();
return ownUsername && (ownUsername === sender || ownUsername === profileUsername);
},
async deleteGuestbookEntry(entry) {
const ok = window.confirm(this.$t('socialnetwork.profile.guestbook.confirmDeleteEntry'));
if (!ok) return;
try {
await apiClient.delete(`/api/socialnetwork/guestbook/entries/${entry.id}`);
await this.loadGuestbookEntries(this.currentPage);
} catch (error) {
console.error('Fehler beim Löschen des Gästebucheintrags:', error);
}
},
async reportModerationTarget(payload, successKey, errorKey) {
const reason = window.prompt(this.$t('socialnetwork.reporting.reasonPrompt'));
if (reason == null) return;
const trimmed = String(reason || '').trim();
if (trimmed.length < 3) {
this.$root.$refs.messageDialog?.open(this.$t('socialnetwork.reporting.reasonTooShort'), this.$t('error.title'));
return;
}
try {
await apiClient.post('/api/moderation/reports', {
...payload,
reason: trimmed,
details: payload.details || ''
});
this.$root.$refs.messageDialog?.open(this.$t(successKey), this.$t('message.title'));
} catch (error) {
console.error('Error creating moderation report:', error);
this.$root.$refs.messageDialog?.open(this.$t(errorKey), this.$t('error.title'));
}
},
async reportProfile() {
await this.reportModerationTarget(
{
targetType: 'user_profile',
targetRef: this.userId,
details: `username=${this.userProfile?.username || ''}`
},
'socialnetwork.reporting.profileReported',
'socialnetwork.reporting.reportError'
);
},
async reportGalleryImage(image) {
await this.reportModerationTarget(
{
targetType: 'gallery_image',
targetId: image.id,
details: `profile=${this.userProfile?.username || ''}; imageTitle=${image?.title || ''}`
},
'socialnetwork.reporting.imageReported',
'socialnetwork.reporting.reportError'
);
},
async reportGuestbookEntry(entry) {
await this.reportModerationTarget(
{
targetType: 'guestbook_entry',
targetId: entry.id,
details: `profile=${this.userProfile?.username || ''}; sender=${entry?.sender || ''}`
},
'socialnetwork.reporting.guestbookReported',
'socialnetwork.reporting.reportError'
);
},
async handleFriendship() {
console.log(this.friendshipState);
if (['none', 'withdrawn'].includes(this.friendshipState)) {
@@ -502,6 +592,10 @@ export default {
text-align: center;
}
.image-list > li > .report-btn {
margin-top: 6px;
}
.folder-name-text {
cursor: pointer;
}
@@ -541,6 +635,26 @@ export default {
color: gray;
}
.entry-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.report-btn,
.delete-btn {
min-height: auto;
padding: 4px 10px;
font-size: 0.75rem;
border-radius: 999px;
}
.profile-report-row {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.pagination {
display: flex;
justify-content: center;

View File

@@ -22,6 +22,10 @@
"view": {
"loading": "Laden…",
"edit": "Bearbeiten",
"reportBlog": "Blog melden",
"reportPost": "Beitrag melden",
"reportBlogSent": "Blog wurde an die Moderation gemeldet.",
"reportPostSent": "Beitrag wurde an die Moderation gemeldet.",
"entriesCount": "{count} Einträge",
"empty": "Keine Einträge vorhanden.",
"fallbackDescription": "Öffentlicher Community-Blog auf YourPart.",

View File

@@ -67,6 +67,7 @@
"m-administration": {
"contactrequests": "Kontaktanfragen",
"moderationReports": "Moderationsmeldungen",
"moderationBadgeTitle": "Offene Moderationsmeldungen",
"users": "Benutzer",
"userrights": "Benutzerrechte",
"m-users": {

View File

@@ -126,8 +126,12 @@
"imageUpload": "Bild",
"submit": "Eintrag absenden",
"noEntries": "Keine Einträge gefunden",
"entryImageAlt": "Bild zum Gästebucheintrag"
"entryImageAlt": "Bild zum Gästebucheintrag",
"reportEntry": "Melden",
"deleteEntry": "Löschen",
"confirmDeleteEntry": "Diesen Gästebucheintrag wirklich löschen?"
},
"reportProfile": "Benutzername/Profil melden",
"interestedInGender": "Interessiert an",
"hasChildren": "Hat Kinder",
"smokes": "Rauchen",
@@ -199,7 +203,8 @@
"title": "Bild"
},
"imagePreviewAlt": "Bildvorschau",
"imageLoadingAlt": "Bild wird geladen"
"imageLoadingAlt": "Bild wird geladen",
"reportImage": "Bild melden"
},
"guestbook": {
"kicker": "Gästebuch",
@@ -209,6 +214,14 @@
"nextPage": "Weiter",
"page": "Seite"
},
"reporting": {
"reasonPrompt": "Kurzer Meldegrund (z. B. Spam, Beleidigung, Hassrede):",
"reasonTooShort": "Bitte gib mindestens 3 Zeichen als Meldegrund ein.",
"profileReported": "Profil wurde an die Moderation gemeldet.",
"imageReported": "Bild wurde an die Moderation gemeldet.",
"guestbookReported": "Gästebucheintrag wurde an die Moderation gemeldet.",
"reportError": "Meldung konnte nicht gesendet werden."
},
"diary": {
"kicker": "Persönliche Einträge",
"intro": "Gedanken, Notizen und kurze Updates in einer ruhigen, persönlichen Ansicht.",

View File

@@ -22,6 +22,10 @@
"view": {
"loading": "Loading…",
"edit": "Edit",
"reportBlog": "Report blog",
"reportPost": "Report post",
"reportBlogSent": "Blog has been reported to moderation.",
"reportPostSent": "Post has been reported to moderation.",
"entriesCount": "{count} entries",
"empty": "No entries available.",
"fallbackDescription": "Public community blog on YourPart.",

View File

@@ -67,6 +67,7 @@
"m-administration": {
"contactrequests": "Contact requests",
"moderationReports": "Moderation reports",
"moderationBadgeTitle": "Open moderation reports",
"users": "Users",
"userrights": "User rights",
"m-users": {

View File

@@ -126,8 +126,12 @@
"imageUpload": "Image",
"submit": "Submit entry",
"noEntries": "No entries found",
"entryImageAlt": "Guestbook entry image"
"entryImageAlt": "Guestbook entry image",
"reportEntry": "Report",
"deleteEntry": "Delete",
"confirmDeleteEntry": "Delete this guestbook entry?"
},
"reportProfile": "Report username/profile",
"interestedInGender": "Interested in",
"hasChildren": "Has children",
"smokes": "Smoking",
@@ -199,7 +203,8 @@
"title": "Image"
},
"imagePreviewAlt": "Image preview",
"imageLoadingAlt": "Loading image"
"imageLoadingAlt": "Loading image",
"reportImage": "Report image"
},
"guestbook": {
"kicker": "Guestbook",
@@ -209,6 +214,14 @@
"nextPage": "Next",
"page": "Page"
},
"reporting": {
"reasonPrompt": "Short report reason (e.g. spam, insult, hate speech):",
"reasonTooShort": "Please enter at least 3 characters as report reason.",
"profileReported": "Profile has been reported to moderation.",
"imageReported": "Image has been reported to moderation.",
"guestbookReported": "Guestbook entry has been reported to moderation.",
"reportError": "Report could not be submitted."
},
"diary": {
"kicker": "Personal entries",
"intro": "Thoughts, notes, and short updates in a calm personal view.",

View File

@@ -10,6 +10,7 @@
</div>
<div v-if="$store.getters.isLoggedIn" class="actions">
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">{{ $t('blog.view.edit') }}</router-link>
<button type="button" class="report-btn" @click="reportBlog">{{ $t('blog.view.reportBlog') }}</button>
</div>
</section>
<div class="blog-content">
@@ -22,6 +23,9 @@
<article v-for="p in items" :key="p.id" class="post">
<h3>{{ p.title }}</h3>
<div class="content" v-html="sanitize(p.content)" />
<div class="post-actions">
<button type="button" class="report-btn" @click="reportPost(p)">{{ $t('blog.view.reportPost') }}</button>
</div>
</article>
<div class="pagination" v-if="total > pageSize">
<button :disabled="page===1" @click="go(page-1)">«</button>
@@ -44,6 +48,7 @@
<script>
import { getBlog, listPosts, createPost } from '@/api/blogApi.js';
import apiClient from '@/utils/axios.js';
import DOMPurify from 'dompurify';
import RichTextEditor from './components/RichTextEditor.vue';
import {
@@ -185,6 +190,41 @@ export default {
this.applyBlogSeo();
},
async go(p) { if (p>=1 && p<=this.pages) await this.fetchPage(p); },
async submitReport(payload, successKey) {
const reason = window.prompt(this.$t('socialnetwork.reporting.reasonPrompt'));
if (reason == null) return;
const trimmed = String(reason || '').trim();
if (trimmed.length < 3) {
this.$root.$refs.messageDialog?.open(this.$t('socialnetwork.reporting.reasonTooShort'), this.$t('error.title'));
return;
}
try {
await apiClient.post('/api/moderation/reports', {
...payload,
reason: trimmed,
details: payload.details || ''
});
this.$root.$refs.messageDialog?.open(this.$t(successKey), this.$t('message.title'));
} catch (error) {
console.error('Blog report failed:', error);
this.$root.$refs.messageDialog?.open(this.$t('socialnetwork.reporting.reportError'), this.$t('error.title'));
}
},
async reportBlog() {
if (!this.blog) return;
await this.submitReport({
targetType: 'blog',
targetId: this.blog.id,
details: `title=${this.blog.title || ''}; owner=${this.blog.owner?.username || ''}`
}, 'blog.view.reportBlogSent');
},
async reportPost(post) {
await this.submitReport({
targetType: 'blog_post',
targetId: post.id,
details: `blogId=${this.blog?.id || ''}; postTitle=${post?.title || ''}`
}, 'blog.view.reportPostSent');
},
async addPost() {
if (!this.newPost.title || !this.newPost.content) return;
const id = this.$route.params.id || this.resolvedId;
@@ -263,6 +303,17 @@ export default {
border-top: 1px solid var(--color-border);
}
.post-actions {
margin-top: 10px;
}
.report-btn {
min-height: auto;
padding: 4px 10px;
font-size: 0.78rem;
border-radius: 999px;
}
.content {
color: var(--color-text-secondary);
}