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',
},
},
};
}