diff --git a/backend/app.js b/backend/app.js index 5342fa9..1b6858a 100644 --- a/backend/app.js +++ b/backend/app.js @@ -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); diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 273dd20..65048e4 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -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; diff --git a/backend/controllers/moderationController.js b/backend/controllers/moderationController.js new file mode 100644 index 0000000..e9a34c6 --- /dev/null +++ b/backend/controllers/moderationController.js @@ -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; diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js index 8c7a287..fc9a6ce 100644 --- a/backend/controllers/navigationController.js +++ b/backend/controllers/navigationController.js @@ -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" diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 95df6d4..1286342 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -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); diff --git a/backend/routers/moderationRouter.js b/backend/routers/moderationRouter.js new file mode 100644 index 0000000..1c030c4 --- /dev/null +++ b/backend/routers/moderationRouter.js @@ -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; diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 497ee15..f37d2e4 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -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'); diff --git a/backend/services/moderationService.js b/backend/services/moderationService.js new file mode 100644 index 0000000..26e6c09 --- /dev/null +++ b/backend/services/moderationService.js @@ -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(); diff --git a/docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md b/docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md new file mode 100644 index 0000000..390ea86 --- /dev/null +++ b/docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md @@ -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. diff --git a/docs/ADSENSE_READINESS.md b/docs/ADSENSE_READINESS.md new file mode 100644 index 0000000..7fd907a --- /dev/null +++ b/docs/ADSENSE_READINESS.md @@ -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. diff --git a/docs/ADSENSE_UGC_MODERATION.md b/docs/ADSENSE_UGC_MODERATION.md new file mode 100644 index 0000000..3f3fad2 --- /dev/null +++ b/docs/ADSENSE_UGC_MODERATION.md @@ -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. diff --git a/frontend/.env.production b/frontend/.env.production index af17443..86d539f 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -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 diff --git a/frontend/.env.server b/frontend/.env.server index af17443..86d539f 100644 --- a/frontend/.env.server +++ b/frontend/.env.server @@ -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 diff --git a/frontend/public/ads.txt b/frontend/public/ads.txt new file mode 100644 index 0000000..c353b6f --- /dev/null +++ b/frontend/public/ads.txt @@ -0,0 +1 @@ +google.com, pub-1104166651501135, DIRECT, f08c47fec0942fa0 diff --git a/frontend/public/index.html b/frontend/public/index.html index abd69e5..d913d4b 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -18,6 +18,7 @@ + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 3309037..962fe1c 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -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 diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 3b881b2..c1d23a7 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -8,6 +8,16 @@ {{ $t('appShell.header.tagline') }} +
+ +
{{ $t('appShell.header.beta') }} @@ -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; diff --git a/frontend/src/components/AppSectionBar.vue b/frontend/src/components/AppSectionBar.vue index b791fac..0fc9bcf 100644 --- a/frontend/src/components/AppSectionBar.vue +++ b/frontend/src/components/AppSectionBar.vue @@ -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', diff --git a/frontend/src/i18n/locales/ceb/general.json b/frontend/src/i18n/locales/ceb/general.json index 7100607..e0a5a91 100644 --- a/frontend/src/i18n/locales/ceb/general.json +++ b/frontend/src/i18n/locales/ceb/general.json @@ -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", diff --git a/frontend/src/i18n/locales/ceb/navigation.json b/frontend/src/i18n/locales/ceb/navigation.json index 318733a..829cf5c 100644 --- a/frontend/src/i18n/locales/ceb/navigation.json +++ b/frontend/src/i18n/locales/ceb/navigation.json @@ -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": { diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index 5d4c614..a78e97b 100644 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -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" + } } } } diff --git a/frontend/src/i18n/locales/de/general.json b/frontend/src/i18n/locales/de/general.json index ceb4897..723245c 100644 --- a/frontend/src/i18n/locales/de/general.json +++ b/frontend/src/i18n/locales/de/general.json @@ -178,6 +178,7 @@ "adminUsers": "Benutzer", "adminUserStatistics": "Benutzerstatistik", "adminContacts": "Kontaktanfragen", + "adminModerationReports": "Moderationsmeldungen", "adminUserRights": "Rechte", "adminForums": "Forumverwaltung", "adminChatRooms": "Chaträume", diff --git a/frontend/src/i18n/locales/de/navigation.json b/frontend/src/i18n/locales/de/navigation.json index 8dcf813..f2eb65d 100644 --- a/frontend/src/i18n/locales/de/navigation.json +++ b/frontend/src/i18n/locales/de/navigation.json @@ -66,6 +66,7 @@ }, "m-administration": { "contactrequests": "Kontaktanfragen", + "moderationReports": "Moderationsmeldungen", "users": "Benutzer", "userrights": "Benutzerrechte", "m-users": { diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index e44f98b..0dfc464 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -255,7 +255,12 @@ "last": "Letzte Seite", "page": "Seite <> von <>" }, - "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": { diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index c2d2da3..ab885c2 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -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" + } } } } diff --git a/frontend/src/i18n/locales/en/general.json b/frontend/src/i18n/locales/en/general.json index 2cebe3b..eaf620b 100644 --- a/frontend/src/i18n/locales/en/general.json +++ b/frontend/src/i18n/locales/en/general.json @@ -178,6 +178,7 @@ "adminUsers": "Users", "adminUserStatistics": "User statistics", "adminContacts": "Contact requests", + "adminModerationReports": "Moderation reports", "adminUserRights": "Rights", "adminForums": "Forum administration", "adminChatRooms": "Chat rooms", diff --git a/frontend/src/i18n/locales/en/navigation.json b/frontend/src/i18n/locales/en/navigation.json index d29246b..812ea3c 100644 --- a/frontend/src/i18n/locales/en/navigation.json +++ b/frontend/src/i18n/locales/en/navigation.json @@ -66,6 +66,7 @@ }, "m-administration": { "contactrequests": "Contact requests", + "moderationReports": "Moderation reports", "users": "Users", "userrights": "User rights", "m-users": { diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index e444dff..1c88b3e 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -255,7 +255,12 @@ "last": "Last page", "page": "Page <> 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": { diff --git a/frontend/src/i18n/locales/es/general.json b/frontend/src/i18n/locales/es/general.json index 526e46a..a5dfc15 100644 --- a/frontend/src/i18n/locales/es/general.json +++ b/frontend/src/i18n/locales/es/general.json @@ -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", diff --git a/frontend/src/i18n/locales/es/navigation.json b/frontend/src/i18n/locales/es/navigation.json index 44f0dbd..ac0bec7 100644 --- a/frontend/src/i18n/locales/es/navigation.json +++ b/frontend/src/i18n/locales/es/navigation.json @@ -66,6 +66,7 @@ }, "m-administration": { "contactrequests": "Solicitudes de contacto", + "moderationReports": "Reportes de moderación", "users": "Usuarios", "userrights": "Permisos de usuario", "m-users": { diff --git a/frontend/src/i18n/locales/fr/general.json b/frontend/src/i18n/locales/fr/general.json index 64254d2..7892bc3 100644 --- a/frontend/src/i18n/locales/fr/general.json +++ b/frontend/src/i18n/locales/fr/general.json @@ -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", diff --git a/frontend/src/i18n/locales/fr/navigation.json b/frontend/src/i18n/locales/fr/navigation.json index 636f84c..7553e30 100644 --- a/frontend/src/i18n/locales/fr/navigation.json +++ b/frontend/src/i18n/locales/fr/navigation.json @@ -66,6 +66,7 @@ }, "m-administration": { "contactrequests": "Kontaktanfragen", + "moderationReports": "Signalements de modération", "users": "Benutzer", "userrights": "Benutzerrechte", "m-users": { diff --git a/frontend/src/router/adminRoutes.js b/frontend/src/router/adminRoutes.js index 860660e..77cc803 100644 --- a/frontend/src/router/adminRoutes.js +++ b/frontend/src/router/adminRoutes.js @@ -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', diff --git a/frontend/src/views/admin/ModerationReportsView.vue b/frontend/src/views/admin/ModerationReportsView.vue new file mode 100644 index 0000000..7a3b1f4 --- /dev/null +++ b/frontend/src/views/admin/ModerationReportsView.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend/src/views/admin/falukant/MapRegionsView.vue b/frontend/src/views/admin/falukant/MapRegionsView.vue index dc802a7..9a8f4c9 100644 --- a/frontend/src/views/admin/falukant/MapRegionsView.vue +++ b/frontend/src/views/admin/falukant/MapRegionsView.vue @@ -63,6 +63,58 @@
+
+

{{ $t('admin.falukant.map.createRegion.title') }}

+ + + + + + + + + + + + + + + + + + +
{{ $t('admin.falukant.map.createRegion.type') }} + +
{{ $t('admin.falukant.map.createRegion.parent') }} + +
{{ $t('admin.falukant.map.createRegion.name') }} + +
+ + + {{ createRegionError }} + +
+
+

{{ selectedRegion.name }}

@@ -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; diff --git a/frontend/src/views/social/ForumTopicView.vue b/frontend/src/views/social/ForumTopicView.vue index 7f56c06..a14857f 100644 --- a/frontend/src/views/social/ForumTopicView.vue +++ b/frontend/src/views/social/ForumTopicView.vue @@ -13,8 +13,13 @@

  • @@ -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;