Added entity reporting.

This commit is contained in:
2026-11-13 00:23:13 +01:00
parent 6143c41668
commit 1d219850d7
18 changed files with 840 additions and 5 deletions
+7
View File
@@ -34,6 +34,13 @@ import Admin from '#/components/admin/admin.vue';
class="link"
>Entity Health</a>
</li>
<li>
<a
href="/admin/reports"
class="link"
>Reports</a>
</li>
</ul>
</Admin>
</template>
+276
View File
@@ -0,0 +1,276 @@
<script setup>
import { format } from 'date-fns';
import { inject, ref } from 'vue';
import Admin from '#/components/admin/admin.vue';
import Checkbox from '#/components/form/checkbox.vue';
import { patch, post } from '#/src/api.js';
import reportReasons from '#/utils/report-reasons.js';
const { pageProps, user } = inject('pageContext');
const { completed } = pageProps;
const reports = ref(pageProps.reports);
async function updateReport(report, update) {
const updatedReport = await patch(`/reports/${report.id}`, update, {
errorFeedback: 'Failed to update report',
appendErrorMessage: true,
}).catch(() => null); // feedback is shown by the api module
if (updatedReport) {
reports.value = reports.value.map((listedReport) => (listedReport.id === report.id
? {
...listedReport,
isRead: updatedReport.isRead,
isCompleted: updatedReport.isCompleted,
}
: listedReport));
}
}
async function banReporter(report) {
if (!confirm(`Ban ${report.user.username} from submitting reports?`)) { // eslint-disable-line no-alert
return;
}
await post('/bans', {
userId: report.user.id,
banIp: true,
scope: 'reports',
}, {
successFeedback: `Banned ${report.user.username} from submitting reports`,
errorFeedback: `Failed to ban ${report.user.username}`,
appendErrorMessage: true,
}).catch(() => {}); // feedback is shown by the api module
}
</script>
<template>
<Admin class="page">
<div class="header">
<h2 class="heading">{{ completed ? 'Completed reports' : 'Open reports' }}</h2>
<a
:href="completed ? '/admin/reports' : '/admin/reports?completed=true'"
class="link toggle-completed"
>{{ completed ? 'Show open reports' : 'Show completed reports' }}</a>
</div>
<p
v-if="reports.length === 0"
class="empty"
>No {{ completed ? 'completed' : 'open' }} reports.</p>
<table
v-else
class="table"
>
<thead class="table-header">
<tr>
<th class="table-heading">Site</th>
<th class="table-heading">Reasons</th>
<th class="table-heading">Comment</th>
<th class="table-heading">Reporter</th>
<th class="table-heading">Date</th>
<th class="table-heading table-check">Read</th>
<th class="table-heading table-check">Done</th>
<th
v-if="user.role === 'admin'"
class="table-heading"
>Ban</th>
</tr>
</thead>
<tbody>
<tr
v-for="report in reports"
:key="`report-${report.id}`"
class="report"
:class="{ unread: !report.isRead, completed: report.isCompleted !== completed }"
>
<td
:title="report.entity?.id"
class="table-cell table-name ellipsis"
>
<a
v-if="report.entity"
:href="`/${report.entity.type}/${report.entity.slug}`"
target="_blank"
class="link"
>{{ report.entity.name }}</a>
</td>
<td class="table-cell table-reasons">
<span
v-for="reason in report.reasons"
:key="`report-${report.id}-${reason}`"
class="reason"
:class="{ urgent: reason === 'inappropriate' }"
>{{ reportReasons[reason] || reason }}</span>
</td>
<td class="table-cell table-comment">{{ report.comment }}</td>
<td class="table-cell table-username ellipsis">
<a
v-if="report.user"
:href="`/user/${report.user.username}`"
target="_blank"
class="link"
>{{ report.user.username }}</a>
<span
v-else
class="deleted"
>deleted user</span>
</td>
<td class="table-cell table-date">{{ format(report.createdAt, 'yyyy-MM-dd HH:mm') }}</td>
<td class="table-cell table-check">
<Checkbox
:checked="report.isRead"
@change="(checked) => updateReport(report, { isRead: checked })"
/>
</td>
<td class="table-cell table-check">
<Checkbox
:checked="report.isCompleted"
@change="(checked) => updateReport(report, { isCompleted: checked })"
/>
</td>
<td
v-if="user.role === 'admin'"
class="table-cell table-actions"
>
<Icon
v-if="report.user"
v-tooltip="`Ban ${report.user.username} from submitting reports`"
icon="blocked"
class="ban"
@click="banReporter(report)"
/>
</td>
</tr>
</tbody>
</table>
</Admin>
</template>
<style scoped>
.page {
flex-grow: 1;
}
.header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 1rem;
}
.heading {
margin: 0;
}
.empty {
color: var(--glass);
font-style: italic;
}
.table {
border-collapse: collapse;
border: 0;
}
.table-heading {
text-align: left;
padding: 0 .5rem .25rem .5rem;
}
.table-cell {
padding: .25rem .5rem .25rem .5rem;
border-collapse: collapse;
border: 0;
}
.table-name {
max-width: 12rem;
}
.table-comment {
min-width: 15rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.table-date {
white-space: nowrap;
}
.table-check {
width: 3rem;
text-align: center;
:deep(.check-container) {
justify-content: center;
}
}
.report {
&:nth-child(2) {
background: var(--glass-weak-50);
}
&.unread .table-name .link {
color: var(--primary);
}
/* marked completed (or reopened) on this page, it moves to the other list on reload */
&.completed {
opacity: .5;
}
}
.reason {
display: inline-block;
padding: .25rem .5rem;
margin: 0 .25rem .25rem 0;
border-radius: .25rem;
background: var(--background-dark-20);
font-size: .9rem;
white-space: nowrap;
&.urgent {
background: var(--warn);
color: var(--text-light);
font-weight: bold;
}
}
.link {
color: var(--text);
}
.toggle-completed {
color: var(--primary);
}
.deleted {
color: var(--glass);
font-style: italic;
}
.ban {
fill: var(--glass);
&:hover {
fill: var(--warn);
cursor: pointer;
}
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { render } from 'vike/abort';
import { canReviewReports, fetchReports } from '#/src/reports.js';
export async function onBeforeRender(pageContext) {
if (!canReviewReports(pageContext.user)) {
throw render(404);
}
const completed = pageContext.urlParsed.search.completed === 'true';
const reports = await fetchReports({ completed }, pageContext.user);
return {
pageContext: {
title: 'Reports',
pageProps: {
reports,
completed,
},
routeParams: {
section: 'reports',
},
},
};
}
+46
View File
@@ -2,6 +2,7 @@
import { computed, inject, ref } from 'vue';
import Domains from '#/components/domains/domains.vue';
import ReportDialog from '#/components/entities/report.vue';
import EntityTile from '#/components/entities/tile.vue';
import Movies from '#/components/movies/movies.vue';
import Scenes from '#/components/scenes/scenes.vue';
@@ -10,6 +11,9 @@ import Heart from '#/components/stashes/heart.vue';
const { pageProps, routeParams, user } = inject('pageContext');
const { entity } = pageProps;
const isReported = ref(pageProps.isReported);
const showReportDialog = ref(false);
const children = ref(null);
const expanded = ref(false);
@@ -77,8 +81,32 @@ const entityUrl = entity.affiliateUrl || entity.url || null;
:item="entity"
class="light"
/>
<template v-if="user">
<Icon
v-if="isReported"
v-tooltip="`Your report for this ${entity.type} is awaiting review`"
icon="warning2"
class="report reported"
/>
<Icon
v-else
v-tooltip="`Report a problem with '${entity.name}'`"
icon="warning2"
class="report"
@click="showReportDialog = true"
/>
</template>
</div>
<ReportDialog
v-if="showReportDialog"
:entity="entity"
@reported="showReportDialog = false; isReported = true;"
@close="showReportDialog = false"
/>
<Domains
:domain="domain"
:path="`/${entity.type}/${entity.slug}`"
@@ -319,6 +347,24 @@ const entityUrl = entity.affiliateUrl || entity.url || null;
display: none;
}
.report {
width: 1.1rem;
height: 100%;
padding: 0 1rem;
margin: -0.25rem 0 0 .75rem;
fill: var(--highlight-weak-10);
&:hover {
cursor: pointer;
fill: var(--warn);
}
&.reported {
fill: var(--primary);
cursor: default;
}
}
.plainurl {
height: 100%;
display: flex;
@@ -5,6 +5,7 @@ import { getCampaignIndex, getRandomCampaigns } from '#/src/campaigns.js';
import entityPrefixes from '#/src/entities-prefixes.js';
import { fetchEntitiesById } from '#/src/entities.js';
import { fetchMovies } from '#/src/movies.js';
import { hasOpenReport } from '#/src/reports.js';
import { fetchScenes } from '#/src/scenes.js';
import { curateMoviesQuery } from '#/src/web/movies.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
@@ -50,11 +51,13 @@ export async function onBeforeRender(pageContext) {
const [
[entity],
entityReleases,
isReported,
] = await Promise.all([
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, {
restriction: pageContext.geo.restriction,
}),
fetchReleases(pageContext, entityId),
hasOpenReport(Number(entityId), pageContext.user),
]);
const entityIds = entity.isIndependent || !entity.parent
@@ -92,6 +95,7 @@ export async function onBeforeRender(pageContext) {
title: entity.name,
pageProps: {
entity,
isReported,
...entityReleases,
},
campaigns: {