feat(Moderation): implement moderation reports feature
All checks were successful
Deploy to production / deploy (push) Successful in 2m1s

- Added moderationRouter to handle moderation-related API routes.
- Introduced new methods in AdminController for fetching all regions, region types, and creating regions.
- Enhanced adminRouter with routes for moderation reports and status updates.
- Updated navigationController to include moderation reports in the admin menu.
- Implemented frontend components for reporting messages in the forum and managing moderation reports.
- Added internationalization support for moderation-related texts in multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-04-27 14:52:19 +02:00
parent 7fc9b55b59
commit a02fe1f008
36 changed files with 1162 additions and 17 deletions

View File

@@ -8,6 +8,16 @@
<span>{{ $t('appShell.header.tagline') }}</span>
</div>
</div>
<div class="header-ad" v-if="showHeaderAd">
<ins
class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-1104166651501135"
:data-ad-slot="adSlotId"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
</div>
<div class="header-meta">
<div class="header-meta__context">
<span class="header-pill">{{ $t('appShell.header.beta') }}</span>
@@ -49,6 +59,7 @@ export default {
name: 'AppHeader',
data() {
return {
adInitialized: false,
/** Endonyme: jede Sprache bezeichnet sich in ihrer eigenen Sprache. */
uiLocaleOptions: [
{ value: 'de', nativeLabel: 'Deutsch' },
@@ -76,9 +87,49 @@ export default {
'status-disconnected': this.daemonConnectionStatus === 'disconnected',
'status-error': this.daemonConnectionStatus === 'error'
};
},
showHeaderAd() {
if (!this.adSlotId) {
return false;
}
const path = this.$route?.path || '';
// Anzeigen bevorzugt auf Bereichen mit dauerhaftem, inhaltlich starkem Content.
return (
path === '/' ||
path.startsWith('/blogs') ||
path.startsWith('/socialnetwork/forum') ||
path.startsWith('/socialnetwork/forumtopic') ||
path.startsWith('/socialnetwork/vocab/courses') ||
path.startsWith('/falukant/home')
);
},
adSlotId() {
const slot = String(import.meta.env.VITE_ADSENSE_HEADER_SLOT || '').trim();
return /^\d{6,}$/.test(slot) ? slot : '';
}
},
watch: {
showHeaderAd: {
immediate: true,
handler(next) {
if (next) {
this.$nextTick(() => this.initHeaderAd());
}
}
}
},
methods: {
initHeaderAd() {
if (!this.showHeaderAd) return;
if (this.adInitialized) return;
if (typeof window === 'undefined' || !window.adsbygoogle) return;
try {
(window.adsbygoogle = window.adsbygoogle || []).push({});
this.adInitialized = true;
} catch (err) {
console.warn('AppHeader: adsense slot init failed', err);
}
},
async onUiLanguageChange(code) {
if (!SUPPORTED_UI_LOCALES.includes(code)) {
return;
@@ -144,7 +195,7 @@ export default {
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 16px;
}
@@ -197,6 +248,17 @@ export default {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
}
.header-ad {
flex: 1 1 260px;
min-width: 180px;
max-width: 560px;
}
.header-ad .adsbygoogle {
min-height: 54px;
}
.header-meta__context {
@@ -327,6 +389,13 @@ export default {
flex-wrap: wrap;
}
.header-ad {
order: 3;
width: 100%;
max-width: none;
flex: 1 1 100%;
}
.header-meta {
width: 100%;
justify-content: space-between;

View File

@@ -73,6 +73,7 @@ const TITLE_MAP = {
AdminUsers: 'sectionBar.titles.adminUsers',
AdminUserStatistics: 'sectionBar.titles.adminUserStatistics',
AdminContacts: 'sectionBar.titles.adminContacts',
AdminModerationReports: 'sectionBar.titles.adminModerationReports',
AdminUserRights: 'sectionBar.titles.adminUserRights',
AdminForums: 'sectionBar.titles.adminForums',
AdminChatRooms: 'sectionBar.titles.adminChatRooms',

View File

@@ -178,6 +178,7 @@
"adminUsers": "Mga user",
"adminUserStatistics": "Estadistika sa user",
"adminContacts": "Mga hangyo sa kontak",
"adminModerationReports": "Mga report sa moderasyon",
"adminUserRights": "Mga katungod",
"adminForums": "Pagdumala sa forum",
"adminChatRooms": "Mga chat room",

View File

@@ -66,6 +66,7 @@
},
"m-administration": {
"contactrequests": "Mga hangyo sa kontak",
"moderationReports": "Mga report sa moderasyon",
"users": "Mga user",
"userrights": "Mga katungod sa user",
"m-users": {

View File

@@ -256,21 +256,33 @@
},
"map": {
"title": "Falukant Karten-Editor (Regionen)",
"description": "Zeichne Rechtecke auf der Falukant-Karte und weise sie Städten zu.",
"description": "Zeichne Rechtecke auf der Falukant-Karte und weise sie Regionen zu.",
"tabs": {
"regions": "Positionen",
"distances": "Entfernungen"
},
"regionList": "Städte",
"regionList": "Regionen",
"noCoords": "Keine Koordinaten gesetzt",
"currentRect": "Aktuelles Rechteck",
"hintDraw": "Wähle eine Stadt und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.",
"saveAll": "Alle geänderten Städte speichern",
"hintDraw": "Wähle eine Region und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.",
"saveAll": "Alle geänderten Regionen speichern",
"createRegion": {
"title": "Neue Region anlegen",
"type": "Regionstyp",
"selectType": "Typ wählen",
"parent": "Parent-Region",
"selectParent": "Parent wählen",
"noParent": "— kein Parent —",
"name": "Name",
"create": "Region anlegen",
"creating": "Lege an…",
"error": "Region konnte nicht angelegt werden."
},
"connectionsTitle": "Verbindungen (region_distance)",
"source": "Von",
"target": "Nach",
"selectSource": "Quellstadt wählen",
"selectTarget": "Zielstadt wählen",
"selectSource": "Quelle wählen",
"selectTarget": "Ziel wählen",
"mode": "Transportart",
"modeLand": "Land",
"modeWater": "Wasser",
@@ -513,6 +525,29 @@
"event": "Event"
}
}
},
"moderationReports": {
"title": "[Admin] - Moderationsmeldungen",
"intro": "Gemeldete Inhalte prüfen, Status setzen und Notizen dokumentieren.",
"statusFilter": "Statusfilter",
"reload": "Neu laden",
"empty": "Keine Meldungen gefunden.",
"target": "Ziel",
"reason": "Meldegrund",
"reporter": "Gemeldet von",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"notePlaceholder": "Notiz für Moderation",
"apply": "Status setzen",
"applySuccess": "Status wurde aktualisiert.",
"applyError": "Status konnte nicht aktualisiert werden.",
"loadError": "Meldungen konnten nicht geladen werden.",
"status": {
"open": "Offen",
"in_review": "In Prüfung",
"resolved": "Erledigt",
"rejected": "Abgelehnt"
}
}
}
}

View File

@@ -178,6 +178,7 @@
"adminUsers": "Benutzer",
"adminUserStatistics": "Benutzerstatistik",
"adminContacts": "Kontaktanfragen",
"adminModerationReports": "Moderationsmeldungen",
"adminUserRights": "Rechte",
"adminForums": "Forumverwaltung",
"adminChatRooms": "Chaträume",

View File

@@ -66,6 +66,7 @@
},
"m-administration": {
"contactrequests": "Kontaktanfragen",
"moderationReports": "Moderationsmeldungen",
"users": "Benutzer",
"userrights": "Benutzerrechte",
"m-users": {

View File

@@ -255,7 +255,12 @@
"last": "Letzte Seite",
"page": "Seite <<page>> von <<of>>"
},
"createNewMesssage": "Antwort senden"
"createNewMesssage": "Antwort senden",
"reportAction": "Melden",
"reportPrompt": "Kurzer Meldegrund (z. B. Spam, Beleidigung, Gewalt):",
"reportReasonTooShort": "Bitte gib mindestens 3 Zeichen als Meldegrund ein.",
"reportSubmitted": "Meldung wurde an die Moderation gesendet.",
"reportError": "Meldung konnte nicht gesendet werden."
},
"friendship": {
"error": {

View File

@@ -309,6 +309,46 @@
"error": "Cleanup failed."
}
},
"map": {
"title": "Falukant Map Editor (Regions)",
"description": "Draw rectangles on the Falukant map and assign them to regions.",
"tabs": {
"regions": "Positions",
"distances": "Distances"
},
"regionList": "Regions",
"noCoords": "No coordinates set",
"currentRect": "Current rectangle",
"hintDraw": "Select a region and drag a rectangle on the map to set its position.",
"saveAll": "Save all changed regions",
"createRegion": {
"title": "Create new region",
"type": "Region type",
"selectType": "Select type",
"parent": "Parent region",
"selectParent": "Select parent",
"noParent": "— no parent —",
"name": "Name",
"create": "Create region",
"creating": "Creating…",
"error": "Could not create region."
},
"connectionsTitle": "Connections (region_distance)",
"source": "From",
"target": "To",
"selectSource": "Select source",
"selectTarget": "Select target",
"mode": "Transport mode",
"modeLand": "Land",
"modeWater": "Water",
"modeAir": "Air",
"distance": "Distance",
"saveConnection": "Save connection",
"pickOnMap": "Pick on map",
"errorSaveConnection": "Could not save the connection.",
"errorDeleteConnection": "Could not delete the connection.",
"confirmDeleteConnection": "Delete connection?"
},
"createNPC": {
"title": "Create NPCs",
"region": "City",
@@ -485,6 +525,29 @@
"event": "Event"
}
}
},
"moderationReports": {
"title": "[Admin] - Moderation Reports",
"intro": "Review reported content, update status, and document moderation notes.",
"statusFilter": "Status filter",
"reload": "Reload",
"empty": "No reports found.",
"target": "Target",
"reason": "Reason",
"reporter": "Reported by",
"createdAt": "Created at",
"actions": "Actions",
"notePlaceholder": "Moderation note",
"apply": "Apply status",
"applySuccess": "Status was updated.",
"applyError": "Status could not be updated.",
"loadError": "Reports could not be loaded.",
"status": {
"open": "Open",
"in_review": "In review",
"resolved": "Resolved",
"rejected": "Rejected"
}
}
}
}

View File

@@ -178,6 +178,7 @@
"adminUsers": "Users",
"adminUserStatistics": "User statistics",
"adminContacts": "Contact requests",
"adminModerationReports": "Moderation reports",
"adminUserRights": "Rights",
"adminForums": "Forum administration",
"adminChatRooms": "Chat rooms",

View File

@@ -66,6 +66,7 @@
},
"m-administration": {
"contactrequests": "Contact requests",
"moderationReports": "Moderation reports",
"users": "Users",
"userrights": "User rights",
"m-users": {

View File

@@ -255,7 +255,12 @@
"last": "Last page",
"page": "Page <<page>> of <<of>>"
},
"createNewMesssage": "Send reply"
"createNewMesssage": "Send reply",
"reportAction": "Report",
"reportPrompt": "Short report reason (e.g. spam, abuse, violence):",
"reportReasonTooShort": "Please enter at least 3 characters as reason.",
"reportSubmitted": "Report was sent to moderation.",
"reportError": "Report could not be sent."
},
"friendship": {
"error": {

View File

@@ -178,6 +178,7 @@
"adminUsers": "Usuarios",
"adminUserStatistics": "Estadísticas de usuarios",
"adminContacts": "Solicitudes de contacto",
"adminModerationReports": "Reportes de moderación",
"adminUserRights": "Permisos",
"adminForums": "Administración del foro",
"adminChatRooms": "Salas de chat",

View File

@@ -66,6 +66,7 @@
},
"m-administration": {
"contactrequests": "Solicitudes de contacto",
"moderationReports": "Reportes de moderación",
"users": "Usuarios",
"userrights": "Permisos de usuario",
"m-users": {

View File

@@ -178,6 +178,7 @@
"adminUsers": "Benutzer",
"adminUserStatistics": "Statistiques des utilisateurs",
"adminContacts": "Kontaktanfragen",
"adminModerationReports": "Signalements de modération",
"adminUserRights": "droite",
"adminForums": "Gestion des forums",
"adminChatRooms": "Chaträume",

View File

@@ -66,6 +66,7 @@
},
"m-administration": {
"contactrequests": "Kontaktanfragen",
"moderationReports": "Signalements de modération",
"users": "Benutzer",
"userrights": "Benutzerrechte",
"m-users": {

View File

@@ -11,6 +11,7 @@ const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
const AdminUsersView = () => import('../views/admin/UsersView.vue');
const AdminAdultVerificationView = () => import('../views/admin/AdultVerificationView.vue');
const AdminEroticModerationView = () => import('../views/admin/EroticModerationView.vue');
const ModerationReportsView = () => import('../views/admin/ModerationReportsView.vue');
const UserStatisticsView = () => import('../views/admin/UserStatisticsView.vue');
const ServicesStatusView = () => import('../views/admin/ServicesStatusView.vue');
@@ -63,6 +64,12 @@ const adminRoutes = [
component: ForumAdminView,
meta: { requiresAuth: true }
},
{
path: '/admin/moderation/reports',
name: 'AdminModerationReports',
component: ModerationReportsView,
meta: { requiresAuth: true }
},
{
path: '/admin/chatrooms',
name: 'AdminChatRooms',

View File

@@ -0,0 +1,137 @@
<template>
<div class="moderation-reports-view">
<h2>{{ $t('admin.moderationReports.title') }}</h2>
<p class="intro">{{ $t('admin.moderationReports.intro') }}</p>
<div class="filters">
<label>
{{ $t('admin.moderationReports.statusFilter') }}
<select v-model="statusFilter" @change="loadReports">
<option value="open">{{ $t('admin.moderationReports.status.open') }}</option>
<option value="in_review">{{ $t('admin.moderationReports.status.in_review') }}</option>
<option value="resolved">{{ $t('admin.moderationReports.status.resolved') }}</option>
<option value="rejected">{{ $t('admin.moderationReports.status.rejected') }}</option>
</select>
</label>
<button type="button" @click="loadReports">{{ $t('admin.moderationReports.reload') }}</button>
</div>
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="reports.length === 0">{{ $t('admin.moderationReports.empty') }}</div>
<table v-else>
<thead>
<tr>
<th>ID</th>
<th>{{ $t('admin.moderationReports.target') }}</th>
<th>{{ $t('admin.moderationReports.reason') }}</th>
<th>{{ $t('admin.moderationReports.reporter') }}</th>
<th>{{ $t('admin.moderationReports.createdAt') }}</th>
<th>{{ $t('admin.moderationReports.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report.id">
<td>#{{ report.id }}</td>
<td>{{ report.targetType }}:{{ report.targetId }}</td>
<td>
<div>{{ report.reason }}</div>
<small v-if="report.details">{{ report.details }}</small>
</td>
<td>{{ report.reporterUsername }}</td>
<td>{{ formatDateTimeLong(report.createdAt) }}</td>
<td class="actions-cell">
<select v-model="draftStatus[report.id]">
<option value="open">{{ $t('admin.moderationReports.status.open') }}</option>
<option value="in_review">{{ $t('admin.moderationReports.status.in_review') }}</option>
<option value="resolved">{{ $t('admin.moderationReports.status.resolved') }}</option>
<option value="rejected">{{ $t('admin.moderationReports.status.rejected') }}</option>
</select>
<input
v-model="draftNote[report.id]"
type="text"
:placeholder="$t('admin.moderationReports.notePlaceholder')"
/>
<button type="button" @click="applyStatus(report)">
{{ $t('admin.moderationReports.apply') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { formatDateTimeLong } from '@/utils/datetime.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'ModerationReportsView',
data() {
return {
loading: false,
statusFilter: 'open',
reports: [],
draftStatus: {},
draftNote: {},
};
},
mounted() {
this.loadReports();
},
methods: {
formatDateTimeLong,
async loadReports() {
this.loading = true;
try {
const { data } = await apiClient.get('/api/admin/moderation/reports', {
params: { status: this.statusFilter },
});
this.reports = Array.isArray(data) ? data : [];
this.reports.forEach((report) => {
this.draftStatus[report.id] = report.status || 'open';
this.draftNote[report.id] = report.reviewerNote || '';
});
} catch (error) {
showApiError(this, error, this.$t('admin.moderationReports.loadError'));
} finally {
this.loading = false;
}
},
async applyStatus(report) {
try {
await apiClient.post(`/api/admin/moderation/reports/${report.id}/status`, {
status: this.draftStatus[report.id] || 'open',
reviewerNote: this.draftNote[report.id] || '',
});
showSuccess(this, this.$t('admin.moderationReports.applySuccess'));
await this.loadReports();
} catch (error) {
showApiError(this, error, this.$t('admin.moderationReports.applyError'));
}
},
},
};
</script>
<style scoped>
.intro {
margin-top: 0;
margin-bottom: 12px;
color: var(--color-text-secondary);
}
.filters {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.actions-cell {
display: grid;
grid-template-columns: 9rem 1fr auto;
gap: 8px;
}
</style>

View File

@@ -63,6 +63,58 @@
<SimpleTabs v-model="activeTab" :tabs="tabs" />
<div v-if="activeTab === 'regions'">
<div class="create-region">
<h3>{{ $t('admin.falukant.map.createRegion.title') }}</h3>
<table class="create-region-table">
<tbody>
<tr>
<td class="label-cell">{{ $t('admin.falukant.map.createRegion.type') }}</td>
<td class="field-cell">
<select v-model.number="newRegion.regionTypeId">
<option :value="null" disabled>{{ $t('admin.falukant.map.createRegion.selectType') }}</option>
<option v-for="t in regionTypes" :key="`rt-${t.id}`" :value="t.id">
{{ regionTypeLabel(t.labelTr) }}
</option>
</select>
</td>
</tr>
<tr>
<td class="label-cell">{{ $t('admin.falukant.map.createRegion.parent') }}</td>
<td class="field-cell">
<select v-model.number="newRegion.parentId" :disabled="!parentRegionEnabled">
<option :value="null" :disabled="parentRegionRequired">
{{ parentRegionEnabled ? $t('admin.falukant.map.createRegion.selectParent') : $t('admin.falukant.map.createRegion.noParent') }}
</option>
<option v-for="r in availableParentRegions" :key="`pr-${r.id}`" :value="r.id">
{{ r.name }}
</option>
</select>
</td>
</tr>
<tr>
<td class="label-cell">{{ $t('admin.falukant.map.createRegion.name') }}</td>
<td class="field-cell">
<input type="text" v-model.trim="newRegion.name" />
</td>
</tr>
<tr>
<td colspan="2" class="create-region-actions-cell">
<button
class="btn btn-primary"
:disabled="creatingRegion || !canCreateRegion"
@click="createRegion"
>
{{ creatingRegion ? $t('admin.falukant.map.createRegion.creating') : $t('admin.falukant.map.createRegion.create') }}
</button>
<span v-if="createRegionError" class="error-text">
{{ createRegionError }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="selectedRegion" class="details">
<h3>{{ selectedRegion.name }}</h3>
<p v-if="selectedRegion.map">
@@ -225,6 +277,7 @@ export default {
data() {
return {
regions: [],
regionTypes: [],
selectedRegion: null,
selectedRegionDirty: false,
dirtyRegionIds: [],
@@ -246,9 +299,52 @@ export default {
{ value: 'distances', label: 'admin.falukant.map.tabs.distances' },
],
pickMode: null,
newRegion: {
regionTypeId: null,
parentId: null,
name: '',
},
creatingRegion: false,
createRegionError: null,
};
},
computed: {
selectedRegionType() {
if (!this.newRegion.regionTypeId) return null;
return this.regionTypes.find((t) => t.id === this.newRegion.regionTypeId) || null;
},
parentRegionEnabled() {
return Boolean(this.selectedRegionType && this.selectedRegionType.parentId != null);
},
parentRegionRequired() {
return this.parentRegionEnabled;
},
availableParentRegions() {
if (!this.parentRegionEnabled) return [];
const parentTypeId = this.selectedRegionType.parentId;
return this.regions.filter((r) => r.regionTypeId === parentTypeId);
},
canCreateRegion() {
if (!this.newRegion.regionTypeId) return false;
if (!this.newRegion.name || !String(this.newRegion.name).trim()) return false;
if (this.parentRegionRequired && !this.newRegion.parentId) return false;
return true;
},
},
watch: {
'newRegion.regionTypeId': function () {
// Wenn der Typ keinen Parent erlaubt, Parent zurücksetzen
if (!this.parentRegionEnabled) {
this.newRegion.parentId = null;
} else if (this.newRegion.parentId) {
// Falls der gewählte Parent nicht mehr passt (z.B. Typ gewechselt), resetten
const ok = this.availableParentRegions.some((r) => r.id === this.newRegion.parentId);
if (!ok) this.newRegion.parentId = null;
}
},
},
async mounted() {
await this.loadRegionTypes();
await this.loadRegions();
await this.loadConnections();
},
@@ -266,9 +362,22 @@ export default {
if (!translated || translated === tKey) return mode;
return translated;
},
regionTypeLabel(labelTr) {
if (!labelTr) return '';
const raw = String(labelTr);
const keys = [
`falukant.politics.regionLevels.${raw}`,
`falukant.regionType.${raw}`,
];
for (const k of keys) {
const tr = this.$t(k);
if (tr && tr !== k) return tr;
}
return raw;
},
async loadRegions() {
try {
const { data } = await apiClient.get('/api/admin/falukant/regions');
const { data } = await apiClient.get('/api/admin/falukant/regions/all');
// Sicherstellen, dass map-Objekte existieren oder null sind
this.regions = (data || []).map(r => ({
...r,
@@ -278,6 +387,49 @@ export default {
console.error('Error loading Falukant regions:', error);
}
},
async loadRegionTypes() {
try {
const { data } = await apiClient.get('/api/admin/falukant/region-types');
this.regionTypes = data || [];
} catch (error) {
console.error('Error loading Falukant region types:', error);
this.regionTypes = [];
}
},
async createRegion() {
if (this.creatingRegion || !this.canCreateRegion) return;
this.creatingRegion = true;
this.createRegionError = null;
try {
const payload = {
name: this.newRegion.name,
regionTypeId: this.newRegion.regionTypeId,
parentId: this.parentRegionEnabled ? this.newRegion.parentId : null,
};
const { data } = await apiClient.post('/api/admin/falukant/regions', payload);
await this.loadRegions();
if (data && data.id) {
const created = this.regions.find((r) => r.id === data.id) || null;
if (created) {
this.activeTab = 'regions';
this.selectRegion(created);
}
}
// Form zurücksetzen (Typ beibehalten, Name leeren)
this.newRegion.name = '';
if (this.parentRegionEnabled) {
this.newRegion.parentId = null;
}
} catch (error) {
console.error('Error creating region:', error);
const errKey = error?.response?.data?.error;
this.createRegionError = errKey ? String(errKey) : this.$t('admin.falukant.map.createRegion.error');
} finally {
this.creatingRegion = false;
}
},
async loadConnections() {
try {
const { data } = await apiClient.get('/api/admin/falukant/region-distances');
@@ -588,6 +740,28 @@ export default {
margin-top: 1rem;
}
.create-region {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.create-region-table {
width: 100%;
border-collapse: collapse;
}
.create-region-actions-cell {
text-align: right;
}
.error-text {
margin-left: 8px;
color: #b00;
font-size: 0.85rem;
}
.hint {
font-size: 0.85rem;
color: #555;

View File

@@ -13,8 +13,13 @@
<li v-for="message in messages" :key="message.id" class="surface-card">
<div v-html="sanitizedMessage(message)"></div>
<div class="footer">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
{{ message.lastMessageUser.username }}
<span class="footer-left">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
{{ message.lastMessageUser.username }}
</span>
<button type="button" class="report-btn" @click="reportMessage(message)">
{{ $t('socialnetwork.forum.reportAction') }}
</button>
</span>
<span>{{ new Date(message.createdAt).toLocaleString() }}</span>
</div>
@@ -103,6 +108,36 @@ export default {
},
sanitizedMessage(message) {
return DOMPurify.sanitize(message.text);
},
async reportMessage(message) {
const reason = window.prompt(this.$t('socialnetwork.forum.reportPrompt'));
if (reason == null) return;
const trimmed = String(reason || '').trim();
if (trimmed.length < 3) {
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.forum.reportReasonTooShort'),
this.$t('error.title')
);
return;
}
try {
await apiClient.post('/api/moderation/reports', {
targetType: 'forum_message',
targetId: message.id,
reason: trimmed,
details: '',
});
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.forum.reportSubmitted'),
this.$t('message.title')
);
} catch (error) {
console.error('Error creating moderation report:', error);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.forum.reportError'),
this.$t('error.title')
);
}
}
}
}
@@ -145,16 +180,29 @@ export default {
font-size: 0.8em;
margin-top: 0.5em;
display: flex;
align-items: center;
gap: 8px;
}
.messages > li > .footer > span:first-child {
.footer-left {
flex: 1;
display: inline-flex;
align-items: center;
gap: 10px;
}
.messages > li > .footer > span:last-child {
text-align: right;
}
.report-btn {
min-height: auto;
padding: 2px 8px;
font-size: 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
}
.editor-container {
margin-top: 1rem;
padding: 0;