feat(Moderation): implement moderation reports feature
All checks were successful
Deploy to production / deploy (push) Successful in 2m1s
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:
@@ -23,6 +23,7 @@ import vocabRouter from './routers/vocabRouter.js';
|
||||
import dashboardRouter from './routers/dashboardRouter.js';
|
||||
import newsRouter from './routers/newsRouter.js';
|
||||
import calendarRouter from './routers/calendarRouter.js';
|
||||
import moderationRouter from './routers/moderationRouter.js';
|
||||
import cors from 'cors';
|
||||
import './jobs/sessionCleanup.js';
|
||||
|
||||
@@ -105,6 +106,7 @@ app.use('/api/contact', contactRouter);
|
||||
app.use('/api/socialnetwork', socialnetworkRouter);
|
||||
app.use('/api/vocab', vocabRouter);
|
||||
app.use('/api/forum', forumRouter);
|
||||
app.use('/api/moderation', moderationRouter);
|
||||
app.use('/api/falukant', falukantRouter);
|
||||
app.use('/api/friendships', friendshipRouter);
|
||||
app.use('/api/models', modelsProxyRouter);
|
||||
|
||||
@@ -54,6 +54,9 @@ class AdminController {
|
||||
// Statistics
|
||||
this.getUserStatistics = this.getUserStatistics.bind(this);
|
||||
this.getFalukantRegions = this.getFalukantRegions.bind(this);
|
||||
this.getFalukantAllRegions = this.getFalukantAllRegions.bind(this);
|
||||
this.getFalukantRegionTypes = this.getFalukantRegionTypes.bind(this);
|
||||
this.createFalukantRegion = this.createFalukantRegion.bind(this);
|
||||
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
|
||||
this.getRegionDistances = this.getRegionDistances.bind(this);
|
||||
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
|
||||
@@ -583,6 +586,42 @@ class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
async getFalukantAllRegions(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const regions = await AdminService.getFalukantAllRegions(userId);
|
||||
res.status(200).json(regions);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getFalukantRegionTypes(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const types = await AdminService.getFalukantRegionTypes(userId);
|
||||
res.status(200).json(types);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async createFalukantRegion(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const created = await AdminService.createFalukantRegion(userId, req.body || {});
|
||||
res.status(200).json(created);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
const status = error.message === 'noaccess' ? 403 : 400;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async updateFalukantRegionMap(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
|
||||
57
backend/controllers/moderationController.js
Normal file
57
backend/controllers/moderationController.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import Joi from 'joi';
|
||||
import moderationService from '../services/moderationService.js';
|
||||
|
||||
const moderationController = {
|
||||
async createReport(req, res) {
|
||||
const schema = Joi.object({
|
||||
targetType: Joi.string().valid('forum_message').required(),
|
||||
targetId: Joi.number().integer().min(1).required(),
|
||||
reason: Joi.string().trim().min(3).max(120).required(),
|
||||
details: Joi.string().allow('').max(2000).optional()
|
||||
});
|
||||
const { error, value } = schema.validate(req.body || {});
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const result = await moderationService.createReport(userId, value);
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error in createReport:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async listReports(req, res) {
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const result = await moderationService.listReports(userId, req.query || {});
|
||||
return res.status(200).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error in listReports:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async updateReportStatus(req, res) {
|
||||
const schema = Joi.object({
|
||||
status: Joi.string().valid('open', 'in_review', 'resolved', 'rejected').required(),
|
||||
reviewerNote: Joi.string().allow('').max(2000).optional()
|
||||
});
|
||||
const { error, value } = schema.validate(req.body || {});
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
try {
|
||||
const { userid: userId } = req.headers;
|
||||
const result = await moderationService.updateReportStatus(userId, req.params.reportId, value);
|
||||
return res.status(200).json(result);
|
||||
} catch (err) {
|
||||
console.error('Error in updateReportStatus:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default moderationController;
|
||||
@@ -278,6 +278,10 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "forum"],
|
||||
path: "/admin/forum"
|
||||
},
|
||||
moderationReports: {
|
||||
visible: ["mainadmin", "forum"],
|
||||
path: "/admin/moderation/reports"
|
||||
},
|
||||
chatrooms: {
|
||||
visible: ["mainadmin", "chatrooms"],
|
||||
path: "/admin/chatrooms"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import AdminController from '../controllers/adminController.js';
|
||||
import moderationController from '../controllers/moderationController.js';
|
||||
|
||||
const router = Router();
|
||||
const adminController = new AdminController();
|
||||
@@ -60,11 +61,16 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g
|
||||
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
||||
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
||||
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
|
||||
router.get('/falukant/region-types', authenticate, adminController.getFalukantRegionTypes);
|
||||
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
|
||||
router.get('/falukant/regions/all', authenticate, adminController.getFalukantAllRegions);
|
||||
router.post('/falukant/regions', authenticate, adminController.createFalukantRegion);
|
||||
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
|
||||
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
|
||||
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
|
||||
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
|
||||
router.get('/moderation/reports', authenticate, moderationController.listReports);
|
||||
router.post('/moderation/reports/:reportId/status', authenticate, moderationController.updateReportStatus);
|
||||
router.post('/falukant/npcs/create', authenticate, adminController.createNPCs);
|
||||
router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus);
|
||||
router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility);
|
||||
|
||||
10
backend/routers/moderationRouter.js
Normal file
10
backend/routers/moderationRouter.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import moderationController from '../controllers/moderationController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.post('/reports', moderationController.createReport);
|
||||
|
||||
export default router;
|
||||
@@ -22,6 +22,10 @@ import RegionData from "../models/falukant/data/region.js";
|
||||
import RegionType from "../models/falukant/type/region.js";
|
||||
import BranchType from "../models/falukant/type/branch.js";
|
||||
import RegionDistance from "../models/falukant/data/region_distance.js";
|
||||
import ProductType from "../models/falukant/type/product.js";
|
||||
import TownProductWorth from "../models/falukant/data/town_product_worth.js";
|
||||
import Weather from "../models/falukant/data/weather.js";
|
||||
import WeatherType from "../models/falukant/type/weather.js";
|
||||
import Room from '../models/chat/room.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
import Image from '../models/community/image.js';
|
||||
@@ -753,6 +757,129 @@ class AdminService {
|
||||
return regions;
|
||||
}
|
||||
|
||||
async getFalukantAllRegions(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const regions = await RegionData.findAll({
|
||||
attributes: ['id', 'name', 'map', 'regionTypeId', 'parentId'],
|
||||
include: [
|
||||
{
|
||||
model: RegionType,
|
||||
as: 'regionType',
|
||||
attributes: ['id', 'labelTr', 'parentId'],
|
||||
},
|
||||
],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
async getFalukantRegionTypes(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const types = await RegionType.findAll({
|
||||
attributes: ['id', 'labelTr', 'parentId'],
|
||||
order: [['labelTr', 'ASC']],
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
async createFalukantRegion(userId, { name, regionTypeId, parentId } = {}) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const regionName = String(name || '').trim();
|
||||
const typeId = Number(regionTypeId);
|
||||
const parentRegionId = parentId == null || parentId === '' ? null : Number(parentId);
|
||||
|
||||
if (!regionName) {
|
||||
throw new Error('missingName');
|
||||
}
|
||||
if (!Number.isInteger(typeId) || typeId <= 0) {
|
||||
throw new Error('missingRegionType');
|
||||
}
|
||||
if (parentRegionId != null && (!Number.isInteger(parentRegionId) || parentRegionId <= 0)) {
|
||||
throw new Error('invalidParent');
|
||||
}
|
||||
|
||||
const regionType = await RegionType.findByPk(typeId, { attributes: ['id', 'labelTr', 'parentId'] });
|
||||
if (!regionType) {
|
||||
throw new Error('regionTypeNotFound');
|
||||
}
|
||||
|
||||
let parentRegion = null;
|
||||
if (parentRegionId != null) {
|
||||
parentRegion = await RegionData.findByPk(parentRegionId, { attributes: ['id', 'regionTypeId'] });
|
||||
if (!parentRegion) {
|
||||
throw new Error('parentRegionNotFound');
|
||||
}
|
||||
}
|
||||
|
||||
if (regionType.parentId != null) {
|
||||
if (parentRegionId == null) {
|
||||
throw new Error('missingParent');
|
||||
}
|
||||
if (!parentRegion || parentRegion.regionTypeId !== regionType.parentId) {
|
||||
throw new Error('invalidParentRegionType');
|
||||
}
|
||||
} else if (parentRegionId != null) {
|
||||
throw new Error('parentNotAllowedForType');
|
||||
}
|
||||
|
||||
const randomWorthPercent = () => Math.floor(Math.random() * 31) + 55; // 55–85
|
||||
|
||||
const createdId = await sequelize.transaction(async (t) => {
|
||||
const region = await RegionData.create(
|
||||
{
|
||||
name: regionName,
|
||||
regionTypeId: regionType.id,
|
||||
parentId: parentRegionId,
|
||||
map: {},
|
||||
},
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
const products = await ProductType.findAll({ attributes: ['id'], transaction: t });
|
||||
if (products.length > 0) {
|
||||
await TownProductWorth.bulkCreate(
|
||||
products.map((p) => ({
|
||||
productId: p.id,
|
||||
regionId: region.id,
|
||||
worthPercent: randomWorthPercent(),
|
||||
})),
|
||||
{ transaction: t, ignoreDuplicates: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (regionType.labelTr === 'city') {
|
||||
const weatherTypes = await WeatherType.findAll({ attributes: ['id'], transaction: t });
|
||||
if (weatherTypes.length > 0) {
|
||||
const randomWeatherType = weatherTypes[Math.floor(Math.random() * weatherTypes.length)];
|
||||
await Weather.findOrCreate({
|
||||
where: { regionId: region.id },
|
||||
defaults: { weatherTypeId: randomWeatherType.id },
|
||||
transaction: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return region.id;
|
||||
});
|
||||
|
||||
const created = await RegionData.findByPk(createdId, {
|
||||
attributes: ['id', 'name', 'map', 'regionTypeId', 'parentId'],
|
||||
include: [{ model: RegionType, as: 'regionType', attributes: ['id', 'labelTr', 'parentId'] }],
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async getTitlesOfNobility(userId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
|
||||
168
backend/services/moderationService.js
Normal file
168
backend/services/moderationService.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import BaseService from './BaseService.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
|
||||
const ALLOWED_TARGET_TYPES = new Set(['forum_message']);
|
||||
const ALLOWED_STATUS = new Set(['open', 'in_review', 'resolved', 'rejected']);
|
||||
|
||||
class ModerationService extends BaseService {
|
||||
constructor() {
|
||||
super();
|
||||
this._tableEnsured = false;
|
||||
}
|
||||
|
||||
async _ensureTable() {
|
||||
if (this._tableEnsured) return;
|
||||
await sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.moderation_report (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_type varchar(64) NOT NULL,
|
||||
target_id bigint NOT NULL,
|
||||
reason varchar(120) NOT NULL,
|
||||
details text NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'open',
|
||||
reporter_user_id bigint NOT NULL REFERENCES community.user(id) ON DELETE CASCADE,
|
||||
reviewer_user_id bigint NULL REFERENCES community.user(id) ON DELETE SET NULL,
|
||||
reviewer_note text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
await sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS moderation_report_target_idx
|
||||
ON community.moderation_report (target_type, target_id);
|
||||
`);
|
||||
await sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS moderation_report_status_idx
|
||||
ON community.moderation_report (status, created_at DESC);
|
||||
`);
|
||||
this._tableEnsured = true;
|
||||
}
|
||||
|
||||
async createReport(hashedUserId, { targetType, targetId, reason, details }) {
|
||||
await this._ensureTable();
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const normalizedTargetType = String(targetType || '').trim();
|
||||
if (!ALLOWED_TARGET_TYPES.has(normalizedTargetType)) {
|
||||
throw new Error('Unsupported target type');
|
||||
}
|
||||
const numericTargetId = Number(targetId);
|
||||
if (!Number.isFinite(numericTargetId) || numericTargetId < 1) {
|
||||
throw new Error('Invalid target id');
|
||||
}
|
||||
const normalizedReason = String(reason || '').trim().slice(0, 120);
|
||||
if (!normalizedReason) {
|
||||
throw new Error('Missing reason');
|
||||
}
|
||||
const normalizedDetails = String(details || '').trim().slice(0, 2000);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.moderation_report
|
||||
(target_type, target_id, reason, details, status, reporter_user_id)
|
||||
VALUES
|
||||
(:targetType, :targetId, :reason, :details, 'open', :reporterUserId)
|
||||
RETURNING id, target_type AS "targetType", target_id AS "targetId", reason, details, status, created_at AS "createdAt"
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
targetType: normalizedTargetType,
|
||||
targetId: numericTargetId,
|
||||
reason: normalizedReason,
|
||||
details: normalizedDetails || null,
|
||||
reporterUserId: user.id
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async listReports(hashedUserId, { status = 'open', limit = 50 } = {}) {
|
||||
await this._ensureTable();
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const isAdmin = await this.hasUserRight(user.id, ['mainadmin', 'forum']);
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
const normalizedStatus = String(status || 'open').trim();
|
||||
const effectiveStatus = ALLOWED_STATUS.has(normalizedStatus) ? normalizedStatus : 'open';
|
||||
const effectiveLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
r.id,
|
||||
r.target_type AS "targetType",
|
||||
r.target_id AS "targetId",
|
||||
r.reason,
|
||||
r.details,
|
||||
r.status,
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
reporter.username AS "reporterUsername",
|
||||
reviewer.username AS "reviewerUsername",
|
||||
r.reviewer_note AS "reviewerNote"
|
||||
FROM community.moderation_report r
|
||||
JOIN community.user reporter ON reporter.id = r.reporter_user_id
|
||||
LEFT JOIN community.user reviewer ON reviewer.id = r.reviewer_user_id
|
||||
WHERE r.status = :status
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT :limit
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
status: effectiveStatus,
|
||||
limit: effectiveLimit
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async updateReportStatus(hashedUserId, reportId, { status, reviewerNote } = {}) {
|
||||
await this._ensureTable();
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const isAdmin = await this.hasUserRight(user.id, ['mainadmin', 'forum']);
|
||||
if (!isAdmin) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
const numericReportId = Number(reportId);
|
||||
if (!Number.isFinite(numericReportId) || numericReportId < 1) {
|
||||
throw new Error('Invalid report id');
|
||||
}
|
||||
const normalizedStatus = String(status || '').trim();
|
||||
if (!ALLOWED_STATUS.has(normalizedStatus)) {
|
||||
throw new Error('Invalid status');
|
||||
}
|
||||
const note = String(reviewerNote || '').trim().slice(0, 2000);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
UPDATE community.moderation_report
|
||||
SET
|
||||
status = :status,
|
||||
reviewer_user_id = :reviewerUserId,
|
||||
reviewer_note = :reviewerNote,
|
||||
updated_at = NOW()
|
||||
WHERE id = :reportId
|
||||
RETURNING id, status, reviewer_note AS "reviewerNote", updated_at AS "updatedAt"
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
status: normalizedStatus,
|
||||
reviewerUserId: user.id,
|
||||
reviewerNote: note || null,
|
||||
reportId: numericReportId
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
if (!rows.length) {
|
||||
throw new Error('Report not found');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
export default new ModerationService();
|
||||
30
docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md
Normal file
30
docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# AdSense Pre-Apply Review (ohne Live-Verifizierung)
|
||||
|
||||
Datum: 2026-04-27
|
||||
|
||||
## Gepruefte Seiten (Content/SEO/Policy)
|
||||
|
||||
1. `/`
|
||||
2. `/blogs`
|
||||
3. `/blogs/:slug` (Route vorhanden, contentbasiert)
|
||||
4. `/vokabeltrainer`
|
||||
5. `/bisaya-lernen`
|
||||
6. `/deutsch-fuer-bisaya`
|
||||
7. `/falukant`
|
||||
8. `/minigames`
|
||||
9. Footer-Dialogpfade: Impressum
|
||||
10. Footer-Dialogpfade: Datenschutz
|
||||
11. Footer-Dialogpfade: Kontakt
|
||||
12. Header-Ad-Placement in App-Layout
|
||||
|
||||
## Ergebnis
|
||||
|
||||
- Header-Ad ist technisch korrekt eingebunden und route-gesteuert.
|
||||
- `ads.txt` ist im Build-Input vorhanden.
|
||||
- Pflichtlinks (Impressum/Datenschutz/Kontakt) sind global im Footer erreichbar.
|
||||
- SEO-Basis (Sitemap, Canonical, Meta-Title/Description) ist fuer Kernseiten vorhanden.
|
||||
- Moderations-/Adminpfade fuer UGC sind vorhanden und dokumentiert (`docs/ADSENSE_UGC_MODERATION.md`).
|
||||
|
||||
## Ausdruecklich ausgenommen
|
||||
|
||||
- Live-Verifizierung auf produktiver Domain (`https://www.your-part.de/ads.txt`) wurde bewusst nicht ausgefuehrt.
|
||||
89
docs/ADSENSE_READINESS.md
Normal file
89
docs/ADSENSE_READINESS.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# AdSense Readiness Checkliste
|
||||
|
||||
Diese Checkliste hilft, YourPart vor und nach der AdSense-Anmeldung policy-sicher zu betreiben.
|
||||
|
||||
## 1) Pflichtseiten und Vertrauen
|
||||
|
||||
- [x] `Impressum` ist von jeder Seite aus erreichbar (Footer oder Header).
|
||||
- [x] `Datenschutzerklaerung` ist von jeder Seite aus erreichbar.
|
||||
- [x] `Kontakt` ist klar sichtbar (Formular oder E-Mail).
|
||||
- [x] Seiten sind nicht im "Baustellen"-Status (keine "coming soon"-Texte auf Hauptseiten).
|
||||
- [x] Navigation ist konsistent und fuehrt auf reale Inhalte.
|
||||
|
||||
## 2) Content-Mindestqualitaet
|
||||
|
||||
- [x] Es gibt ausreichend indexierbare Inhalte mit Substanz (Blog, Forum, Lerninhalte, Falukant-Texte).
|
||||
- [x] Landing-Pages enthalten mehr als nur kurze Marketingtexte.
|
||||
- [x] Keine duennen Seiten mit nur 1-2 Zeilen Text.
|
||||
- [x] Keine automatisch erzeugten, redundanten oder fast identischen Seiten.
|
||||
- [x] Sprachversionen sind gepflegt (keine groesseren Mischungen aus Fallback-Texten).
|
||||
|
||||
## 3) Ad-Platzierung und UX
|
||||
|
||||
- [x] Anzeigen stoeren nicht zentrale Navigation/Funktionen.
|
||||
- [x] Im Header bleibt genug Abstand zu interaktiven Elementen (z. B. Sprachwahl).
|
||||
- [x] Auf kleinen Screens bleibt die Anzeige responsive ohne Layout-Bruch.
|
||||
- [x] Keine irrefuehrenden Labels wie "Download", "Start", "Weiter" direkt neben Ads.
|
||||
- [x] Keine Anzeige auf Seiten mit sehr wenig Content.
|
||||
|
||||
## 4) Technische Anforderungen
|
||||
|
||||
- [x] AdSense Script einmalig im `head` eingebunden (`frontend/public/index.html`).
|
||||
- [ ] `ads.txt` unter `https://www.your-part.de/ads.txt` erreichbar.
|
||||
- [x] AdSlot-ID ist gesetzt (`VITE_ADSENSE_HEADER_SLOT`).
|
||||
- [x] In Produktion wird nur mit echter Slot-ID ausgeliefert.
|
||||
- [x] Keine JS-Fehler durch wiederholtes `adsbygoogle.push`.
|
||||
|
||||
## 5) Policy-Risiko und Moderation
|
||||
|
||||
- [x] UGC-Bereiche (Forum, Kommentare, Chat) haben Moderation/Reporting.
|
||||
- [x] Erotik-/18+-Bereiche sind klar getrennt und nicht AdSense-besetzt.
|
||||
- [x] Keine Anzeigen auf Seiten mit potentiell problematischem Inhalt.
|
||||
- [x] Keine gekauften Klicks/Traffic-Anreize auf Anzeigen.
|
||||
- [x] Kein "Klick auf Werbung, um zu unterstuetzen"-Wording.
|
||||
|
||||
## 6) SEO und Crawling-Basis
|
||||
|
||||
- [x] `robots.txt` blockiert nicht versehentlich wichtige Content-Seiten.
|
||||
- [x] `sitemap.xml` ist aktuell und liefert relevante URLs.
|
||||
- [x] Canonical-Tags sind auf Kernseiten korrekt gesetzt.
|
||||
- [x] Titles/Descriptions sind sinnvoll und nicht generisch leer.
|
||||
|
||||
## 7) Vor Antrag (finaler Durchlauf)
|
||||
|
||||
- [x] Mindestens 10-20 qualitativ starke, oeffentliche Inhaltsseiten pruefen.
|
||||
- [x] Manuell Mobile + Desktop testen (Header-Ad sichtbar, aber nicht stoerend).
|
||||
- [x] Kein sichtbarer Platzhaltertext in Kernbereichen.
|
||||
- [x] Impressum/Datenschutz/Kontakt von Startseite in max. 1 Klick erreichbar.
|
||||
- [ ] `ads.txt` im Browser geoeffnet und korrekt.
|
||||
|
||||
## 8) Nach Freischaltung (Betrieb)
|
||||
|
||||
- [ ] Einnahmen + RPM beobachten, aber UX nicht verschlechtern.
|
||||
- [ ] Ads nur auf Seiten mit ausreichend Inhalt ausrollen.
|
||||
- [ ] Regelmaessig Policy Center in AdSense pruefen.
|
||||
- [ ] Bei neuen Features vor Livegang kurz gegen diese Liste testen.
|
||||
|
||||
---
|
||||
|
||||
## Projekt-Hinweise fuer aktuellen Stand
|
||||
|
||||
- Header-Slot ist bereits eingebaut in `frontend/src/components/AppHeader.vue`.
|
||||
- Script ist in `frontend/public/index.html` eingebunden.
|
||||
- `ads.txt` liegt unter `frontend/public/ads.txt`.
|
||||
- Der Header-Slot wird aktuell nur auf inhaltlich staerkeren Routen angezeigt und benoetigt `VITE_ADSENSE_HEADER_SLOT`.
|
||||
|
||||
## Ergebnis dieser Pruefung (heute)
|
||||
|
||||
### Erfuellt
|
||||
|
||||
- Footer verlinkt `Impressum`, `Datenschutz`, `Kontakt` global (`frontend/src/components/AppFooter.vue`).
|
||||
- AdSense Script ist im Head eingebunden (`frontend/public/index.html`).
|
||||
- `ads.txt` ist im Repo vorhanden (`frontend/public/ads.txt`).
|
||||
- Header-Ad ist zwischen Logo und Sprachwahl und route-gebunden eingebaut (`frontend/src/components/AppHeader.vue`).
|
||||
- Slot-Init ist gegen mehrfaches Push abgesichert (`adInitialized`-Guard).
|
||||
|
||||
### Offen vor Antrag
|
||||
|
||||
- Deployment-Check: `https://www.your-part.de/ads.txt` oeffnen und Inhalt verifizieren.
|
||||
- Deployment-Check der produktiven ENV-Variablen nach Rollout.
|
||||
51
docs/ADSENSE_UGC_MODERATION.md
Normal file
51
docs/ADSENSE_UGC_MODERATION.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# UGC Moderation & AdSense Safety
|
||||
|
||||
Stand: 2026-04-27
|
||||
|
||||
## Ziel
|
||||
|
||||
Sicherstellen, dass nutzergenerierte Inhalte (UGC) fuer AdSense policy-konform betrieben werden:
|
||||
|
||||
- keine Monetarisierung direkt auf problematischen Inhalten
|
||||
- nachvollziehbare Moderations- und Eskalationspfade
|
||||
- klare Trennung zwischen oeffentlichen Landing-Inhalten und geschuetzten Community-Bereichen
|
||||
|
||||
## Geltungsbereich
|
||||
|
||||
- Forum / Themen
|
||||
- Chat-Raeume
|
||||
- Kontaktanfragen / Support-Kommunikation
|
||||
- Erotik-Bereiche (mit separater Moderation)
|
||||
|
||||
## Bereits vorhandene Moderations-Werkzeuge im Projekt
|
||||
|
||||
- Admin-Forumverwaltung (`frontend/src/dialogues/admin/ForumAdminView.vue`)
|
||||
- Loesch- und Verwaltungsaktionen fuer Foreninhalte.
|
||||
- Admin-Chatraumverwaltung (`frontend/src/views/admin/ChatRoomsView.vue`, `frontend/src/views/admin/RoomsView.vue`)
|
||||
- Loeschen und Verwalten von Raeumen.
|
||||
- Kontaktanfragen-Backoffice (`frontend/src/views/admin/ContactsView.vue`)
|
||||
- Moderierbare/anwaltbare Kontaktprozesse.
|
||||
- Erotik-Moderationsbereich in Navigation (`navigation.*.admin.eroticmoderation`).
|
||||
|
||||
## AdSense-Betriebsregeln (verbindlich)
|
||||
|
||||
1. Anzeigen nur auf inhaltlich starken, stabilen Seiten schalten.
|
||||
2. Keine Anzeigen in UGC-Bereichen mit erhoehtem Risiko ohne aktive Moderation.
|
||||
3. Keine Anzeigen in/nahe 18+-Bereichen.
|
||||
4. Keine CTA-Texte, die Klicks auf Anzeigen nahelegen.
|
||||
5. Bei Policy-Hinweisen aus AdSense sofort Placement temporär reduzieren.
|
||||
|
||||
## Operativer Ablauf
|
||||
|
||||
1. Neue Community-Funktion vor Livegang auf UGC-Risiko pruefen.
|
||||
2. Entscheiden, ob Ads dort zugelassen werden oder nicht.
|
||||
3. Moderationsweg dokumentieren (zustandiges Admin-Panel).
|
||||
4. Nach Go-Live kurzfristig Inhalte/Reports beobachten.
|
||||
|
||||
## Nachweis fuer AdSense-Readiness
|
||||
|
||||
Diese Datei dient als interne, revisionsfaehige Dokumentation, dass:
|
||||
|
||||
- Moderationsoberflaechen vorhanden sind,
|
||||
- Risikozonen erkannt sind,
|
||||
- und Anzeigenplatzierung bewusst eingeschraenkt wird.
|
||||
@@ -4,3 +4,5 @@ VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
|
||||
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
|
||||
VITE_CHAT_WS_URL=wss://www.your-part.de:1235
|
||||
VITE_SOCKET_IO_URL=https://www.your-part.de:4443
|
||||
|
||||
VITE_ADSENSE_HEADER_SLOT=1104166651
|
||||
|
||||
@@ -4,3 +4,5 @@ VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
|
||||
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
|
||||
VITE_CHAT_WS_URL=wss://www.your-part.de:1235
|
||||
VITE_SOCKET_IO_URL=https://www.your-part.de:4443
|
||||
|
||||
VITE_ADSENSE_HEADER_SLOT=1104166651
|
||||
|
||||
1
frontend/public/ads.txt
Normal file
1
frontend/public/ads.txt
Normal file
@@ -0,0 +1 @@
|
||||
google.com, pub-1104166651501135, DIRECT, f08c47fec0942fa0
|
||||
@@ -18,6 +18,7 @@
|
||||
<meta name="twitter:title" content="YourPart – Online-Community, Forum, Chat, Vokabeltrainer & Browsergames" />
|
||||
<meta name="twitter:description" content="Forum, Chat, Blogs, kostenloser Vokabeltrainer und Browsergames inkl. Falukant – gratis im Browser, ohne Download. Jetzt in der Beta testen." />
|
||||
<meta name="theme-color" content="#F9A22C" />
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-1104166651501135" crossorigin="anonymous"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B4fysdFj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B7qXuEZt.css">
|
||||
</head>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /blogs
|
||||
Allow: /vokabeltrainer
|
||||
Allow: /bisaya-lernen
|
||||
Allow: /deutsch-fuer-bisaya
|
||||
Allow: /falukant
|
||||
Allow: /minigames
|
||||
Disallow: /activate
|
||||
Disallow: /admin/
|
||||
Disallow: /friends
|
||||
Disallow: /personal/
|
||||
Disallow: /settings/
|
||||
Disallow: /socialnetwork/diary
|
||||
Disallow: /socialnetwork/forum/
|
||||
Disallow: /socialnetwork/forumtopic/
|
||||
Disallow: /socialnetwork/gallery
|
||||
Disallow: /socialnetwork/guestbook
|
||||
Disallow: /socialnetwork/search
|
||||
Disallow: /socialnetwork/vocab/
|
||||
Disallow: /falukant/home
|
||||
Disallow: /falukant/create
|
||||
Disallow: /falukant/branch/
|
||||
Disallow: /falukant/moneyhistory
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"adminUsers": "Benutzer",
|
||||
"adminUserStatistics": "Benutzerstatistik",
|
||||
"adminContacts": "Kontaktanfragen",
|
||||
"adminModerationReports": "Moderationsmeldungen",
|
||||
"adminUserRights": "Rechte",
|
||||
"adminForums": "Forumverwaltung",
|
||||
"adminChatRooms": "Chaträume",
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
},
|
||||
"m-administration": {
|
||||
"contactrequests": "Kontaktanfragen",
|
||||
"moderationReports": "Moderationsmeldungen",
|
||||
"users": "Benutzer",
|
||||
"userrights": "Benutzerrechte",
|
||||
"m-users": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"adminUsers": "Users",
|
||||
"adminUserStatistics": "User statistics",
|
||||
"adminContacts": "Contact requests",
|
||||
"adminModerationReports": "Moderation reports",
|
||||
"adminUserRights": "Rights",
|
||||
"adminForums": "Forum administration",
|
||||
"adminChatRooms": "Chat rooms",
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
},
|
||||
"m-administration": {
|
||||
"contactrequests": "Contact requests",
|
||||
"moderationReports": "Moderation reports",
|
||||
"users": "Users",
|
||||
"userrights": "User rights",
|
||||
"m-users": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
},
|
||||
"m-administration": {
|
||||
"contactrequests": "Solicitudes de contacto",
|
||||
"moderationReports": "Reportes de moderación",
|
||||
"users": "Usuarios",
|
||||
"userrights": "Permisos de usuario",
|
||||
"m-users": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
},
|
||||
"m-administration": {
|
||||
"contactrequests": "Kontaktanfragen",
|
||||
"moderationReports": "Signalements de modération",
|
||||
"users": "Benutzer",
|
||||
"userrights": "Benutzerrechte",
|
||||
"m-users": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
137
frontend/src/views/admin/ModerationReportsView.vue
Normal file
137
frontend/src/views/admin/ModerationReportsView.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user