feat: report page, take action on reports
This commit is contained in:
parent
a0ba712632
commit
cacd3a30b7
14 changed files with 502 additions and 14 deletions
|
@ -113,24 +113,30 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
|
||||||
) => writer.WriteStringValue(value.Value.ToString());
|
) => writer.WriteStringValue(value.Value.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private class JsonConverter : JsonConverter<Snowflake>
|
private class JsonConverter : JsonConverter<Snowflake?>
|
||||||
{
|
{
|
||||||
public override void WriteJson(
|
public override void WriteJson(
|
||||||
JsonWriter writer,
|
JsonWriter writer,
|
||||||
Snowflake value,
|
Snowflake? value,
|
||||||
JsonSerializer serializer
|
JsonSerializer serializer
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
writer.WriteValue(value.Value.ToString());
|
if (value != null)
|
||||||
|
writer.WriteValue(value.Value.ToString());
|
||||||
|
else
|
||||||
|
writer.WriteNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Snowflake ReadJson(
|
public override Snowflake? ReadJson(
|
||||||
JsonReader reader,
|
JsonReader reader,
|
||||||
Type objectType,
|
Type objectType,
|
||||||
Snowflake existingValue,
|
Snowflake? existingValue,
|
||||||
bool hasExistingValue,
|
bool hasExistingValue,
|
||||||
JsonSerializer serializer
|
JsonSerializer serializer
|
||||||
) => ulong.Parse((string)reader.Value!);
|
) =>
|
||||||
|
reader.TokenType is not (JsonToken.None or JsonToken.Null)
|
||||||
|
? ulong.Parse((string)reader.Value!)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TypeConverter : System.ComponentModel.TypeConverter
|
private class TypeConverter : System.ComponentModel.TypeConverter
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
// ReSharper disable NotAccessedPositionalProperty.Global
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
@ -80,15 +81,17 @@ public record CreateReportRequest(ReportReason Reason, string? Context = null);
|
||||||
|
|
||||||
public record IgnoreReportRequest(string? Reason = null);
|
public record IgnoreReportRequest(string? Reason = null);
|
||||||
|
|
||||||
public record WarnUserRequest(
|
public class WarnUserRequest
|
||||||
string Reason,
|
{
|
||||||
FieldsToClear[]? ClearFields = null,
|
public required string Reason { get; init; }
|
||||||
Snowflake? MemberId = null,
|
public FieldsToClear[]? ClearFields { get; init; }
|
||||||
Snowflake? ReportId = null
|
public Snowflake? MemberId { get; init; }
|
||||||
);
|
public Snowflake? ReportId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null);
|
public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null);
|
||||||
|
|
||||||
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
public enum FieldsToClear
|
public enum FieldsToClear
|
||||||
{
|
{
|
||||||
DisplayName,
|
DisplayName,
|
||||||
|
|
|
@ -154,6 +154,12 @@ public class ModerationService(
|
||||||
target.DeletedAt = clock.GetCurrentInstant();
|
target.DeletedAt = clock.GetCurrentInstant();
|
||||||
target.DeletedBy = moderator.Id;
|
target.DeletedBy = moderator.Id;
|
||||||
|
|
||||||
|
if (report != null)
|
||||||
|
{
|
||||||
|
report.Status = ReportStatus.Closed;
|
||||||
|
db.Update(report);
|
||||||
|
}
|
||||||
|
|
||||||
if (!clearProfile)
|
if (!clearProfile)
|
||||||
{
|
{
|
||||||
db.Update(target);
|
db.Update(target);
|
||||||
|
@ -334,6 +340,12 @@ public class ModerationService(
|
||||||
db.Update(targetUser);
|
db.Update(targetUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (report != null)
|
||||||
|
{
|
||||||
|
report.Status = ReportStatus.Closed;
|
||||||
|
db.Update(report);
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
|
|
90
Foxnouns.Frontend/src/lib/actions/modaction.ts
Normal file
90
Foxnouns.Frontend/src/lib/actions/modaction.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||||
|
import type { AuditLogEntry, ClearableField } from "$api/models/moderation";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import { type RequestEvent } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
type ModactionResponse = { ok: boolean; resp: AuditLogEntry | null; error: RawApiError | null };
|
||||||
|
type ModactionFunction = (evt: RequestEvent) => Promise<ModactionResponse>;
|
||||||
|
|
||||||
|
export default function createModactionAction(
|
||||||
|
type: "ignore" | "warn" | "suspend",
|
||||||
|
requireReason: boolean,
|
||||||
|
): ModactionFunction {
|
||||||
|
return async function ({ request, fetch, cookies }) {
|
||||||
|
const body = await request.formData();
|
||||||
|
const userId = body.get("user") as string;
|
||||||
|
const memberId = body.get("member") as string | null;
|
||||||
|
const reportId = body.get("report") as string | null;
|
||||||
|
const reason = body.get("reason") as string | null;
|
||||||
|
|
||||||
|
if (!reportId && type === "ignore") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
resp: null,
|
||||||
|
error: {
|
||||||
|
status: 400,
|
||||||
|
message: "Bad request",
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
errors: [
|
||||||
|
{ key: "report", errors: [{ message: "Ignoring a report requires a report ID" }] },
|
||||||
|
],
|
||||||
|
} satisfies RawApiError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reason && requireReason) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
resp: null,
|
||||||
|
error: {
|
||||||
|
status: 400,
|
||||||
|
message: "Bad request",
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
errors: [{ key: "reason", errors: [{ message: "You must give a reason" }] }],
|
||||||
|
} satisfies RawApiError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let clearFields: ClearableField[] | undefined = undefined;
|
||||||
|
if (type === "warn") {
|
||||||
|
clearFields = body.getAll("clear-fields") as ClearableField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let path: string;
|
||||||
|
if (type === "warn") path = `/moderation/warnings/${userId}`;
|
||||||
|
else if (type === "suspend") path = `/moderation/suspensions/${userId}`;
|
||||||
|
else path = `/moderation/reports/${reportId}/ignore`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<AuditLogEntry>("POST", path, {
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
body: {
|
||||||
|
reason: reason,
|
||||||
|
// These are ignored by POST /reports/{id}/ignore
|
||||||
|
member_id: memberId,
|
||||||
|
report_id: reportId,
|
||||||
|
// This is ignored by everything but POST /warnings/{id}
|
||||||
|
clear_fields: clearFields,
|
||||||
|
// This is ignored by everything but POST /suspensions/{id}
|
||||||
|
clear_profile: !!body.get("clear-profile"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, resp, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { ok: false, error: e.obj, resp: null };
|
||||||
|
log.error("could not take action on %s:", path, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModactions() {
|
||||||
|
return {
|
||||||
|
ignore: createModactionAction("ignore", false),
|
||||||
|
warn: createModactionAction("warn", true),
|
||||||
|
suspend: createModactionAction("suspend", true),
|
||||||
|
};
|
||||||
|
}
|
|
@ -81,6 +81,7 @@ export async function apiRequest<TResponse, TRequest = unknown>(
|
||||||
|
|
||||||
if (resp.status < 200 || resp.status > 299) {
|
if (resp.status < 200 || resp.status > 299) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
|
log.error("Received error for request to %s %s:", method, path, err);
|
||||||
if ("code" in err) throw new ApiError(err);
|
if ("code" in err) throw new ApiError(err);
|
||||||
else throw new ApiError();
|
else throw new ApiError();
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,6 +71,12 @@ export type PartialReport = {
|
||||||
target_type: "USER" | "MEMBER";
|
target_type: "USER" | "MEMBER";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReportDetails = {
|
||||||
|
report: Report;
|
||||||
|
user: User;
|
||||||
|
member?: Member;
|
||||||
|
};
|
||||||
|
|
||||||
export type QueriedUser = {
|
export type QueriedUser = {
|
||||||
user: User;
|
user: User;
|
||||||
member_list_hidden: boolean;
|
member_list_hidden: boolean;
|
||||||
|
@ -80,3 +86,28 @@ export type QueriedUser = {
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
auth_methods?: AuthMethod[];
|
auth_methods?: AuthMethod[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WarnUserRequest = {
|
||||||
|
reason: string;
|
||||||
|
clear_fields?: ClearableField[];
|
||||||
|
member_id?: string;
|
||||||
|
report_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuspendUserRequest = {
|
||||||
|
reason: string;
|
||||||
|
clear_profile: boolean;
|
||||||
|
report_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum ClearableField {
|
||||||
|
DisplayName = "DISPLAY_NAME",
|
||||||
|
Avatar = "AVATAR",
|
||||||
|
Bio = "BIO",
|
||||||
|
Links = "LINKS",
|
||||||
|
Names = "NAMES",
|
||||||
|
Pronouns = "PRONOUNS",
|
||||||
|
Fields = "FIELDS",
|
||||||
|
Flags = "FLAGS",
|
||||||
|
CustomPreferences = "CUSTOM_PREFERENCES",
|
||||||
|
}
|
||||||
|
|
73
Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte
Normal file
73
Foxnouns.Frontend/src/lib/components/admin/ActionForm.svelte
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { ClearableField } from "$api/models/moderation";
|
||||||
|
import FormStatusMarker, { type FormError } from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import { TabContent, TabPane } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
let {
|
||||||
|
userId,
|
||||||
|
reportId,
|
||||||
|
memberId,
|
||||||
|
form,
|
||||||
|
}: { userId: string; reportId?: string; memberId?: string; form: FormError } = $props();
|
||||||
|
|
||||||
|
let fields = $derived.by(() => {
|
||||||
|
const fields = [];
|
||||||
|
for (const value of Object.values(ClearableField)) {
|
||||||
|
fields.push({ value });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="user" value={userId} />
|
||||||
|
{#if memberId}
|
||||||
|
<input type="hidden" name="member" value={memberId} />
|
||||||
|
{/if}
|
||||||
|
{#if reportId}
|
||||||
|
<input type="hidden" name="report" value={reportId} />
|
||||||
|
{/if}
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
<textarea name="reason" class="form-control" style="height: 200px;"></textarea>
|
||||||
|
<TabContent>
|
||||||
|
{#if reportId}
|
||||||
|
<TabPane tabId="ignore" tab="Ignore">
|
||||||
|
<button type="submit" formaction="?/ignore" class="btn btn-secondary">Ignore report</button>
|
||||||
|
</TabPane>
|
||||||
|
{/if}
|
||||||
|
<TabPane tabId="warn" tab="Warn" active>
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2">
|
||||||
|
{#each fields as field}
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="clear-fields"
|
||||||
|
value={field.value}
|
||||||
|
id="reason-{field.value}"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="reason-{field.value}">
|
||||||
|
<code>{field.value}</code>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" formaction="?/warn" class="btn btn-danger">Warn user</button>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tabId="suspend" tab="Suspend">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
value="yes"
|
||||||
|
name="clear-profile"
|
||||||
|
id="clear-profile"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="clear-profile">Clear the user's profile?</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" formaction="?/suspend" class="btn btn-danger">Suspend user</button>
|
||||||
|
</TabPane>
|
||||||
|
</TabContent>
|
||||||
|
</form>
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PartialUser } from "$api/models";
|
||||||
|
import Avatar from "$components/Avatar.svelte";
|
||||||
|
import { idTimestamp } from "$lib";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
type Props = { user: PartialUser };
|
||||||
|
let { user }: Props = $props();
|
||||||
|
|
||||||
|
let createdAt = $derived(idTimestamp(user.id).toLocaleString(DateTime.DATETIME_SHORT));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/@{user.username}">
|
||||||
|
<Avatar
|
||||||
|
name={user.username}
|
||||||
|
url={user.avatar_url}
|
||||||
|
lazyLoad
|
||||||
|
alt={$t("avatar-tooltip", { name: "@" + user.username })}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<p class="m-2">
|
||||||
|
<a class="text-reset fs-5 text-break" href="/@{user.username}">
|
||||||
|
@{user.username}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>Created {createdAt}</p>
|
||||||
|
</div>
|
|
@ -1,10 +1,14 @@
|
||||||
|
<script module lang="ts">
|
||||||
|
export type FormError = { error: RawApiError | null; ok: boolean } | null;
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Icon } from "@sveltestrap/sveltestrap";
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { RawApiError } from "$api/error";
|
import type { RawApiError } from "$api/error";
|
||||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
|
||||||
type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string };
|
type Props = { form: FormError | null; successMessage?: string };
|
||||||
let { form, successMessage }: Props = $props();
|
let { form, successMessage }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ export const load = async ({ parent, fetch, cookies }) => {
|
||||||
|
|
||||||
const reports = await apiRequest<Report[]>("GET", "/moderation/reports", { fetch, cookies });
|
const reports = await apiRequest<Report[]>("GET", "/moderation/reports", { fetch, cookies });
|
||||||
const staleReportCount = reports.filter(
|
const staleReportCount = reports.filter(
|
||||||
(r) => idTimestamp(r.id).diffNow(["days"]).days >= 7,
|
(r) => idTimestamp(r.id).diffNow(["days"]).days <= -7,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
23
Foxnouns.Frontend/src/routes/admin/reports/+page.server.ts
Normal file
23
Foxnouns.Frontend/src/routes/admin/reports/+page.server.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { Report } from "$api/models/moderation";
|
||||||
|
|
||||||
|
export const load = async ({ url, fetch, cookies }) => {
|
||||||
|
const before = url.searchParams.get("before");
|
||||||
|
const after = url.searchParams.get("after");
|
||||||
|
const byReporter = url.searchParams.get("by-reporter");
|
||||||
|
const byTarget = url.searchParams.get("by-target");
|
||||||
|
const includeClosed = url.searchParams.get("include-closed") === "true";
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (before) params.set("before", before);
|
||||||
|
if (after) params.set("after", after);
|
||||||
|
if (byReporter) params.set("by-reporter", byReporter);
|
||||||
|
if (byTarget) params.set("by-target", byTarget);
|
||||||
|
if (includeClosed) params.set("include-closed", "true");
|
||||||
|
|
||||||
|
const reports = await apiRequest<Report[]>("GET", `/moderation/reports?${params.toString()}`, {
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { reports, url: url.toString(), byReporter, byTarget, before, after };
|
||||||
|
};
|
125
Foxnouns.Frontend/src/routes/admin/reports/+page.svelte
Normal file
125
Foxnouns.Frontend/src/routes/admin/reports/+page.svelte
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Link45deg from "svelte-bootstrap-icons/lib/Link45deg.svelte";
|
||||||
|
import Funnel from "svelte-bootstrap-icons/lib/Funnel.svelte";
|
||||||
|
import FunnelFill from "svelte-bootstrap-icons/lib/FunnelFill.svelte";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { idTimestamp } from "$lib";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const addReporter = (id: string | null) => {
|
||||||
|
const url = new URL(data.url);
|
||||||
|
if (id) url.searchParams.set("by-reporter", id);
|
||||||
|
else url.searchParams.delete("by-reporter");
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTarget = (id: string | null) => {
|
||||||
|
const url = new URL(data.url);
|
||||||
|
if (id) url.searchParams.set("by-target", id);
|
||||||
|
else url.searchParams.delete("by-target");
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addBefore = (id: string) => {
|
||||||
|
const url = new URL(data.url);
|
||||||
|
url.searchParams.delete("after");
|
||||||
|
url.searchParams.set("before", id);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAfter = (id: string) => {
|
||||||
|
const url = new URL(data.url);
|
||||||
|
url.searchParams.delete("before");
|
||||||
|
url.searchParams.set("after", id);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Reports • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h2>Reports</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#if data.byTarget}
|
||||||
|
<li>Filtering by target (<a href={addTarget(null)}>clear</a>)</li>
|
||||||
|
{/if}
|
||||||
|
{#if data.byReporter}
|
||||||
|
<li>Filtering by reporter (<a href={addReporter(null)}>clear</a>)</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if data.before}
|
||||||
|
<a href={addAfter(data.before)}>Show newer reports</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col"></th>
|
||||||
|
<th scope="col">User</th>
|
||||||
|
<th scope="col">Member</th>
|
||||||
|
<th scope="col">Reporter</th>
|
||||||
|
<th scope="col">Reason</th>
|
||||||
|
<th scope="col">Context?</th>
|
||||||
|
<th scope="col">Created at</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.reports as report (report.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/reports/{report.id}">
|
||||||
|
<Link45deg />
|
||||||
|
<span class="visually-hidden">Open report</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/@{report.target_user.username}">@{report.target_user.username}</a>
|
||||||
|
(<a href={addTarget(report.target_user.id)}>
|
||||||
|
{#if data.byTarget === report.target_user.id}<FunnelFill />{:else}<Funnel />{/if}
|
||||||
|
</a>)
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if report.target_member}
|
||||||
|
<a href="@/{report.target_user.username}/{report.target_member.name}">
|
||||||
|
{report.target_member.name}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<em>(none)</em>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/@{report.reporter.username}">{report.reporter.username}</a>
|
||||||
|
(<a href={addReporter(report.reporter.id)}>
|
||||||
|
{#if data.byReporter === report.reporter.id}<FunnelFill />{:else}<Funnel />{/if}
|
||||||
|
</a>)
|
||||||
|
</td>
|
||||||
|
<td><code>{report.reason}</code></td>
|
||||||
|
<td>
|
||||||
|
{#if report.context}
|
||||||
|
{$t("yes")}
|
||||||
|
{:else}
|
||||||
|
{$t("no")}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{idTimestamp(report.id).toLocaleString(DateTime.DATETIME_SHORT)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{#if data.reports.length === 100}
|
||||||
|
<a href={addBefore(data.reports[data.reports.length - 1].id)}>Show older reports</a>
|
||||||
|
{/if}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { ReportDetails } from "$api/models/moderation";
|
||||||
|
import { createModactions } from "$lib/actions/modaction";
|
||||||
|
|
||||||
|
export const load = async ({ params, fetch, cookies }) => {
|
||||||
|
const resp = await apiRequest<ReportDetails>("GET", `/moderation/reports/${params.id}`, {
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { report: resp.report, user: resp.user, member: resp.member };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = createModactions();
|
78
Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.svelte
Normal file
78
Foxnouns.Frontend/src/routes/admin/reports/[id]/+page.svelte
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Member } from "$api/models/member";
|
||||||
|
import type { User } from "$api/models/user";
|
||||||
|
import ActionForm from "$components/admin/ActionForm.svelte";
|
||||||
|
import PartialProfileCard from "$components/admin/PartialProfileCard.svelte";
|
||||||
|
import ProfileHeader from "$components/profile/ProfileHeader.svelte";
|
||||||
|
import MemberCard from "$components/profile/user/MemberCard.svelte";
|
||||||
|
import { renderMarkdown } from "$lib/markdown";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
let { report, user, member } = $derived(data);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Report on @{user.username} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Target user</h3>
|
||||||
|
<PartialProfileCard user={report.target_user} />
|
||||||
|
</div>
|
||||||
|
{#if report.target_member}
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Target member</h3>
|
||||||
|
<MemberCard
|
||||||
|
username={report.target_user.username}
|
||||||
|
member={report.target_member}
|
||||||
|
allPreferences={{}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Reporter</h3>
|
||||||
|
<PartialProfileCard user={report.reporter} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Reason</h3>
|
||||||
|
<p><code>{report.reason}</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Context</h3>
|
||||||
|
<p>
|
||||||
|
{#if report.context}
|
||||||
|
{@html renderMarkdown(report.context)}
|
||||||
|
{:else}
|
||||||
|
<em>(no context given)</em>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<h3>Take action</h3>
|
||||||
|
<ActionForm
|
||||||
|
userId={report.target_user.id}
|
||||||
|
reportId={report.id}
|
||||||
|
memberId={report.target_member?.id}
|
||||||
|
{form}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if report.snapshot}
|
||||||
|
<h3>Profile at time of report</h3>
|
||||||
|
<hr />
|
||||||
|
{#if report.target_type === "USER"}
|
||||||
|
{@const snapshot = report.snapshot as User}
|
||||||
|
<ProfileHeader profile={snapshot} name="@{snapshot.username}" />
|
||||||
|
{:else}
|
||||||
|
{@const snapshot = report.snapshot as Member}
|
||||||
|
<ProfileHeader profile={snapshot} name="{snapshot.name} (@{snapshot.user.username})" />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
Loading…
Add table
Add a link
Reference in a new issue