diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 63fa36a..b0a5d51 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -29,6 +29,8 @@ class AuthController { } catch (error) { if (error.message === 'credentialsinvalid') { res.status(404).json({ error: error.message }); + } else if (error.message === 'userblocked') { + res.status(403).json({ error: error.message }); } else { res.status(500).json({ error: error.message }); } diff --git a/backend/controllers/moderationController.js b/backend/controllers/moderationController.js index e9a34c6..9de02ce 100644 --- a/backend/controllers/moderationController.js +++ b/backend/controllers/moderationController.js @@ -3,12 +3,23 @@ import moderationService from '../services/moderationService.js'; const moderationController = { async createReport(req, res) { + const allowedTargetTypes = [ + 'forum_message', + 'gallery_image', + 'guestbook_entry', + 'one_to_one_message', + 'diary_entry', + 'user_profile', + 'blog', + 'blog_post' + ]; const schema = Joi.object({ - targetType: Joi.string().valid('forum_message').required(), - targetId: Joi.number().integer().min(1).required(), + targetType: Joi.string().valid(...allowedTargetTypes).required(), + targetId: Joi.number().integer().min(1).optional(), + targetRef: Joi.string().trim().max(255).allow('').optional(), reason: Joi.string().trim().min(3).max(120).required(), details: Joi.string().allow('').max(2000).optional() - }); + }).or('targetId', 'targetRef'); const { error, value } = schema.validate(req.body || {}); if (error) { return res.status(400).json({ error: error.details[0].message }); @@ -51,6 +62,17 @@ const moderationController = { console.error('Error in updateReportStatus:', err); return res.status(400).json({ error: err.message }); } + }, + + async getOpenReportCount(req, res) { + try { + const { userid: userId } = req.headers; + const result = await moderationService.getOpenReportCount(userId); + return res.status(200).json(result); + } catch (err) { + console.error('Error in getOpenReportCount:', err); + return res.status(400).json({ error: err.message }); + } } }; diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js index 2164a5c..f8bf796 100644 --- a/backend/middleware/authMiddleware.js +++ b/backend/middleware/authMiddleware.js @@ -10,6 +10,9 @@ export const authenticate = async (req, res, next) => { if (!user) { return res.status(401).json({ error: 'Unauthorized: Invalid credentials' }); } + if (!user.active) { + return res.status(403).json({ error: 'Unauthorized: User blocked' }); + } try { await updateUserTimestamp(user.id); } catch (error) { diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 1286342..b30dc34 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -70,6 +70,7 @@ router.get('/falukant/region-distances', authenticate, adminController.getRegion 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.get('/moderation/reports/open-count', authenticate, moderationController.getOpenReportCount); 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); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index ebdc1e7..ad721b8 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -1663,9 +1663,15 @@ class AdminService { } if (typeof data.active === 'boolean') { updates.active = data.active; + if (data.active === false) { + updates.authCode = null; + } } if (Object.keys(updates).length === 0) return { id: user.hashedId, username: user.username, active: user.active }; await user.update(updates); + if (typeof data.active === 'boolean') { + await notifyUser(user.hashedId, 'userAccessChanged', { active: !!data.active }); + } return { id: user.hashedId, username: user.username, active: user.active }; } diff --git a/backend/services/authService.js b/backend/services/authService.js index 1c45785..30c98a0 100644 --- a/backend/services/authService.js +++ b/backend/services/authService.js @@ -105,6 +105,9 @@ export const loginUser = async ({ username, password }) => { if (!user) { throw new Error('credentialsinvalid'); } + if (!user.active) { + throw new Error('userblocked'); + } const match = await bcrypt.compare(password, user.password); if (!match) { throw new Error('credentialsinvalid'); diff --git a/backend/services/moderationService.js b/backend/services/moderationService.js index 04cc566..bf4a3bf 100644 --- a/backend/services/moderationService.js +++ b/backend/services/moderationService.js @@ -1,7 +1,17 @@ import BaseService from './BaseService.js'; import { sequelize } from '../utils/sequelize.js'; +import { notifyUser } from '../utils/socket.js'; -const ALLOWED_TARGET_TYPES = new Set(['forum_message']); +const ALLOWED_TARGET_TYPES = new Set([ + 'forum_message', + 'gallery_image', + 'guestbook_entry', + 'one_to_one_message', + 'diary_entry', + 'user_profile', + 'blog', + 'blog_post' +]); const ALLOWED_STATUS = new Set(['open', 'in_review', 'resolved', 'rejected']); class ModerationService extends BaseService { @@ -16,7 +26,8 @@ class ModerationService extends BaseService { CREATE TABLE IF NOT EXISTS community.moderation_report ( id bigserial PRIMARY KEY, target_type varchar(64) NOT NULL, - target_id bigint NOT NULL, + target_id bigint NULL, + target_ref varchar(255) NULL, reason varchar(120) NOT NULL, details text NULL, status varchar(32) NOT NULL DEFAULT 'open', @@ -31,6 +42,14 @@ class ModerationService extends BaseService { CREATE INDEX IF NOT EXISTS moderation_report_target_idx ON community.moderation_report (target_type, target_id); `); + await sequelize.query(` + ALTER TABLE community.moderation_report + ADD COLUMN IF NOT EXISTS target_ref varchar(255) NULL; + `); + await sequelize.query(` + ALTER TABLE community.moderation_report + ALTER COLUMN target_id DROP NOT NULL; + `); await sequelize.query(` CREATE INDEX IF NOT EXISTS moderation_report_status_idx ON community.moderation_report (status, created_at DESC); @@ -38,7 +57,7 @@ class ModerationService extends BaseService { this._tableEnsured = true; } - async createReport(hashedUserId, { targetType, targetId, reason, details }) { + async createReport(hashedUserId, { targetType, targetId, targetRef, reason, details }) { await this._ensureTable(); const user = await this.getUserByHashedId(hashedUserId); const normalizedTargetType = String(targetType || '').trim(); @@ -46,8 +65,10 @@ class ModerationService extends BaseService { throw new Error('Unsupported target type'); } const numericTargetId = Number(targetId); - if (!Number.isFinite(numericTargetId) || numericTargetId < 1) { - throw new Error('Invalid target id'); + const hasTargetId = Number.isFinite(numericTargetId) && numericTargetId >= 1; + const normalizedTargetRef = String(targetRef || '').trim().slice(0, 255); + if (!hasTargetId && !normalizedTargetRef) { + throw new Error('Invalid target'); } const normalizedReason = String(reason || '').trim().slice(0, 120); if (!normalizedReason) { @@ -58,15 +79,16 @@ class ModerationService extends BaseService { const rows = await sequelize.query( ` INSERT INTO community.moderation_report - (target_type, target_id, reason, details, status, reporter_user_id) + (target_type, target_id, target_ref, 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" + (:targetType, :targetId, :targetRef, :reason, :details, 'open', :reporterUserId) + RETURNING id, target_type AS "targetType", target_id AS "targetId", target_ref AS "targetRef", reason, details, status, created_at AS "createdAt" `, { replacements: { targetType: normalizedTargetType, - targetId: numericTargetId, + targetId: hasTargetId ? numericTargetId : null, + targetRef: normalizedTargetRef || null, reason: normalizedReason, details: normalizedDetails || null, reporterUserId: user.id @@ -75,7 +97,9 @@ class ModerationService extends BaseService { } ); - return rows[0]; + const created = rows[0]; + await this._notifyModeratorsAboutChange('created', created.id); + return created; } async listReports(hashedUserId, { status = 'open', limit = 50 } = {}) { @@ -95,6 +119,7 @@ class ModerationService extends BaseService { r.id, r.target_type AS "targetType", r.target_id AS "targetId", + r.target_ref AS "targetRef", fm.title_id AS "topicId", ft.forum_id AS "forumId", r.reason, @@ -167,7 +192,66 @@ class ModerationService extends BaseService { if (!rows.length) { throw new Error('Report not found'); } - return rows[0]; + const updated = rows[0]; + await this._notifyModeratorsAboutChange('status_changed', numericReportId); + return updated; + } + + async getOpenReportCount(hashedUserId) { + 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 rows = await sequelize.query( + ` + SELECT COUNT(*)::int AS count + FROM community.moderation_report + WHERE status = 'open' + `, + { type: sequelize.QueryTypes.SELECT } + ); + return { openCount: Number(rows?.[0]?.count || 0) }; + } + + async _notifyModeratorsAboutChange(kind, reportId) { + try { + const moderatorRows = await sequelize.query( + ` + SELECT DISTINCT u.hashed_id AS "hashedId" + FROM community.user u + JOIN community.user_right ur ON ur.user_id = u.id + JOIN type.user_right urt ON urt.id = ur.right_type_id + WHERE u.active = true + AND urt.title IN ('mainadmin', 'forum') + `, + { type: sequelize.QueryTypes.SELECT } + ); + const countRows = await sequelize.query( + ` + SELECT COUNT(*)::int AS count + FROM community.moderation_report + WHERE status = 'open' + `, + { type: sequelize.QueryTypes.SELECT } + ); + const openCount = Number(countRows?.[0]?.count || 0); + await Promise.all( + (moderatorRows || []) + .map((row) => row?.hashedId) + .filter(Boolean) + .map((hashedId) => + notifyUser(hashedId, 'moderationReportChanged', { + kind, + reportId, + openCount + }) + ) + ); + } catch (error) { + console.error('Failed to notify moderators about moderation report change:', error); + } } } diff --git a/frontend/src/components/AppNavigation.vue b/frontend/src/components/AppNavigation.vue index d8281e6..68562bc 100644 --- a/frontend/src/components/AppNavigation.vue +++ b/frontend/src/components/AppNavigation.vue @@ -48,7 +48,17 @@ :style="`background-image:url('/images/icons/${subitem.icon}')`" class="submenu-icon" > - {{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }} + + {{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }} + + ⚠ + {{ moderationOpenCount }} + + 18+ { + if (Number.isFinite(Number(payload.openCount))) { + this.moderationOpenCount = Number(payload.openCount); + return; + } + await this.fetchModerationOpenCount(); + }; + this._userAccessChangedHandler = async (payload = {}) => { + if (payload.active === false) { + this.$root?.$refs?.messageDialog?.open?.('Dein Account wurde gesperrt.'); + await this.logout(); + } + }; sock.on('forumschanged', this._forumsChangedHandler); sock.on('friendloginchanged', this._friendLoginChangedHandler); sock.on('reloadmenu', this._reloadMenuHandler); sock.on('adultVerificationChanged', this._adultVerificationChangedHandler); + sock.on('moderationReportChanged', this._moderationReportChangedHandler); + sock.on('userAccessChanged', this._userAccessChangedHandler); }, unregisterSocketListeners() { @@ -276,10 +311,14 @@ export default { if (this._friendLoginChangedHandler) sock.off('friendloginchanged', this._friendLoginChangedHandler); if (this._reloadMenuHandler) sock.off('reloadmenu', this._reloadMenuHandler); if (this._adultVerificationChangedHandler) sock.off('adultVerificationChanged', this._adultVerificationChangedHandler); + if (this._moderationReportChangedHandler) sock.off('moderationReportChanged', this._moderationReportChangedHandler); + if (this._userAccessChangedHandler) sock.off('userAccessChanged', this._userAccessChangedHandler); this._forumsChangedHandler = null; this._friendLoginChangedHandler = null; this._reloadMenuHandler = null; this._adultVerificationChangedHandler = null; + this._moderationReportChangedHandler = null; + this._userAccessChangedHandler = null; }, updateViewportState() { @@ -461,6 +500,19 @@ export default { this.vocabLanguagesList = []; } }, + async fetchModerationOpenCount() { + try { + const adminChildren = this.menu?.administration?.children || {}; + if (!adminChildren.moderationReports) { + this.moderationOpenCount = 0; + return; + } + const res = await apiClient.get('/api/admin/moderation/reports/open-count'); + this.moderationOpenCount = Number(res?.data?.openCount || 0); + } catch (_) { + this.moderationOpenCount = 0; + } + }, openForum(forumId) { this.$router.push({ name: 'Forum', params: { id: forumId } }); @@ -800,6 +852,29 @@ a { border-radius: 14px; } +.submenu-label-row { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.moderation-alert-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: #d32f2f; + color: #fff; + border-radius: 999px; + padding: 2px 7px; + font-weight: 800; + font-size: 0.72rem; + line-height: 1; +} + +.moderation-alert-icon { + font-size: 0.78rem; +} + .submenu1 > li:hover { color: var(--color-text-primary); background-color: rgba(248, 162, 43, 0.12); diff --git a/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue b/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue index bb4f7cb..3b53cd5 100644 --- a/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue +++ b/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue @@ -17,6 +17,11 @@ + + + {{ $t('socialnetwork.profile.reportProfile') }} + + {{ $t(`socialnetwork.profile.${key}`) }} @@ -44,6 +49,9 @@ {{ image.title }} + + {{ $t('socialnetwork.gallery.reportImage') }} + @@ -82,6 +90,19 @@ {{ entry.sender }} + + + {{ $t('socialnetwork.profile.guestbook.reportEntry') }} + + + {{ $t('socialnetwork.profile.guestbook.deleteEntry') }} + + {{ @@ -357,6 +378,75 @@ export default { console.error('Error fetching image:', error); } }, + canDeleteGuestbookEntry(entry) { + const ownUsername = String(this.user?.username || '').toLowerCase(); + const profileUsername = String(this.userProfile?.username || '').toLowerCase(); + const sender = String(entry?.sender || '').toLowerCase(); + return ownUsername && (ownUsername === sender || ownUsername === profileUsername); + }, + async deleteGuestbookEntry(entry) { + const ok = window.confirm(this.$t('socialnetwork.profile.guestbook.confirmDeleteEntry')); + if (!ok) return; + try { + await apiClient.delete(`/api/socialnetwork/guestbook/entries/${entry.id}`); + await this.loadGuestbookEntries(this.currentPage); + } catch (error) { + console.error('Fehler beim Löschen des Gästebucheintrags:', error); + } + }, + async reportModerationTarget(payload, successKey, errorKey) { + const reason = window.prompt(this.$t('socialnetwork.reporting.reasonPrompt')); + if (reason == null) return; + const trimmed = String(reason || '').trim(); + if (trimmed.length < 3) { + this.$root.$refs.messageDialog?.open(this.$t('socialnetwork.reporting.reasonTooShort'), this.$t('error.title')); + return; + } + try { + await apiClient.post('/api/moderation/reports', { + ...payload, + reason: trimmed, + details: payload.details || '' + }); + this.$root.$refs.messageDialog?.open(this.$t(successKey), this.$t('message.title')); + } catch (error) { + console.error('Error creating moderation report:', error); + this.$root.$refs.messageDialog?.open(this.$t(errorKey), this.$t('error.title')); + } + }, + async reportProfile() { + await this.reportModerationTarget( + { + targetType: 'user_profile', + targetRef: this.userId, + details: `username=${this.userProfile?.username || ''}` + }, + 'socialnetwork.reporting.profileReported', + 'socialnetwork.reporting.reportError' + ); + }, + async reportGalleryImage(image) { + await this.reportModerationTarget( + { + targetType: 'gallery_image', + targetId: image.id, + details: `profile=${this.userProfile?.username || ''}; imageTitle=${image?.title || ''}` + }, + 'socialnetwork.reporting.imageReported', + 'socialnetwork.reporting.reportError' + ); + }, + async reportGuestbookEntry(entry) { + await this.reportModerationTarget( + { + targetType: 'guestbook_entry', + targetId: entry.id, + details: `profile=${this.userProfile?.username || ''}; sender=${entry?.sender || ''}` + }, + 'socialnetwork.reporting.guestbookReported', + 'socialnetwork.reporting.reportError' + ); + }, async handleFriendship() { console.log(this.friendshipState); if (['none', 'withdrawn'].includes(this.friendshipState)) { @@ -502,6 +592,10 @@ export default { text-align: center; } +.image-list > li > .report-btn { + margin-top: 6px; +} + .folder-name-text { cursor: pointer; } @@ -541,6 +635,26 @@ export default { color: gray; } +.entry-actions { + margin-top: 8px; + display: flex; + gap: 8px; +} + +.report-btn, +.delete-btn { + min-height: auto; + padding: 4px 10px; + font-size: 0.75rem; + border-radius: 999px; +} + +.profile-report-row { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + .pagination { display: flex; justify-content: center; diff --git a/frontend/src/i18n/locales/de/blog.json b/frontend/src/i18n/locales/de/blog.json index e8da9bf..5b32344 100644 --- a/frontend/src/i18n/locales/de/blog.json +++ b/frontend/src/i18n/locales/de/blog.json @@ -22,6 +22,10 @@ "view": { "loading": "Laden…", "edit": "Bearbeiten", + "reportBlog": "Blog melden", + "reportPost": "Beitrag melden", + "reportBlogSent": "Blog wurde an die Moderation gemeldet.", + "reportPostSent": "Beitrag wurde an die Moderation gemeldet.", "entriesCount": "{count} Einträge", "empty": "Keine Einträge vorhanden.", "fallbackDescription": "Öffentlicher Community-Blog auf YourPart.", diff --git a/frontend/src/i18n/locales/de/navigation.json b/frontend/src/i18n/locales/de/navigation.json index f2eb65d..08a52df 100644 --- a/frontend/src/i18n/locales/de/navigation.json +++ b/frontend/src/i18n/locales/de/navigation.json @@ -67,6 +67,7 @@ "m-administration": { "contactrequests": "Kontaktanfragen", "moderationReports": "Moderationsmeldungen", + "moderationBadgeTitle": "Offene 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 0dfc464..2c97827 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -126,8 +126,12 @@ "imageUpload": "Bild", "submit": "Eintrag absenden", "noEntries": "Keine Einträge gefunden", - "entryImageAlt": "Bild zum Gästebucheintrag" + "entryImageAlt": "Bild zum Gästebucheintrag", + "reportEntry": "Melden", + "deleteEntry": "Löschen", + "confirmDeleteEntry": "Diesen Gästebucheintrag wirklich löschen?" }, + "reportProfile": "Benutzername/Profil melden", "interestedInGender": "Interessiert an", "hasChildren": "Hat Kinder", "smokes": "Rauchen", @@ -199,7 +203,8 @@ "title": "Bild" }, "imagePreviewAlt": "Bildvorschau", - "imageLoadingAlt": "Bild wird geladen" + "imageLoadingAlt": "Bild wird geladen", + "reportImage": "Bild melden" }, "guestbook": { "kicker": "Gästebuch", @@ -209,6 +214,14 @@ "nextPage": "Weiter", "page": "Seite" }, + "reporting": { + "reasonPrompt": "Kurzer Meldegrund (z. B. Spam, Beleidigung, Hassrede):", + "reasonTooShort": "Bitte gib mindestens 3 Zeichen als Meldegrund ein.", + "profileReported": "Profil wurde an die Moderation gemeldet.", + "imageReported": "Bild wurde an die Moderation gemeldet.", + "guestbookReported": "Gästebucheintrag wurde an die Moderation gemeldet.", + "reportError": "Meldung konnte nicht gesendet werden." + }, "diary": { "kicker": "Persönliche Einträge", "intro": "Gedanken, Notizen und kurze Updates in einer ruhigen, persönlichen Ansicht.", diff --git a/frontend/src/i18n/locales/en/blog.json b/frontend/src/i18n/locales/en/blog.json index 747f901..1310766 100644 --- a/frontend/src/i18n/locales/en/blog.json +++ b/frontend/src/i18n/locales/en/blog.json @@ -22,6 +22,10 @@ "view": { "loading": "Loading…", "edit": "Edit", + "reportBlog": "Report blog", + "reportPost": "Report post", + "reportBlogSent": "Blog has been reported to moderation.", + "reportPostSent": "Post has been reported to moderation.", "entriesCount": "{count} entries", "empty": "No entries available.", "fallbackDescription": "Public community blog on YourPart.", diff --git a/frontend/src/i18n/locales/en/navigation.json b/frontend/src/i18n/locales/en/navigation.json index 812ea3c..01ae899 100644 --- a/frontend/src/i18n/locales/en/navigation.json +++ b/frontend/src/i18n/locales/en/navigation.json @@ -67,6 +67,7 @@ "m-administration": { "contactrequests": "Contact requests", "moderationReports": "Moderation reports", + "moderationBadgeTitle": "Open 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 1c88b3e..ac61bdc 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -126,8 +126,12 @@ "imageUpload": "Image", "submit": "Submit entry", "noEntries": "No entries found", - "entryImageAlt": "Guestbook entry image" + "entryImageAlt": "Guestbook entry image", + "reportEntry": "Report", + "deleteEntry": "Delete", + "confirmDeleteEntry": "Delete this guestbook entry?" }, + "reportProfile": "Report username/profile", "interestedInGender": "Interested in", "hasChildren": "Has children", "smokes": "Smoking", @@ -199,7 +203,8 @@ "title": "Image" }, "imagePreviewAlt": "Image preview", - "imageLoadingAlt": "Loading image" + "imageLoadingAlt": "Loading image", + "reportImage": "Report image" }, "guestbook": { "kicker": "Guestbook", @@ -209,6 +214,14 @@ "nextPage": "Next", "page": "Page" }, + "reporting": { + "reasonPrompt": "Short report reason (e.g. spam, insult, hate speech):", + "reasonTooShort": "Please enter at least 3 characters as report reason.", + "profileReported": "Profile has been reported to moderation.", + "imageReported": "Image has been reported to moderation.", + "guestbookReported": "Guestbook entry has been reported to moderation.", + "reportError": "Report could not be submitted." + }, "diary": { "kicker": "Personal entries", "intro": "Thoughts, notes, and short updates in a calm personal view.", diff --git a/frontend/src/views/blog/BlogView.vue b/frontend/src/views/blog/BlogView.vue index ce570ef..2ac1376 100644 --- a/frontend/src/views/blog/BlogView.vue +++ b/frontend/src/views/blog/BlogView.vue @@ -10,6 +10,7 @@ {{ $t('blog.view.edit') }} + {{ $t('blog.view.reportBlog') }} @@ -22,6 +23,9 @@ {{ p.title }} + + {{ $t('blog.view.reportPost') }} + « @@ -44,6 +48,7 @@
{{ image.title }}