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()); | ||||
|     } | ||||
| 
 | ||||
|     private class JsonConverter : JsonConverter<Snowflake> | ||||
|     private class JsonConverter : JsonConverter<Snowflake?> | ||||
|     { | ||||
|         public override void WriteJson( | ||||
|             JsonWriter writer, | ||||
|             Snowflake value, | ||||
|             Snowflake? value, | ||||
|             JsonSerializer serializer | ||||
|         ) | ||||
|         { | ||||
|             if (value != null) | ||||
|                 writer.WriteValue(value.Value.ToString()); | ||||
|             else | ||||
|                 writer.WriteNull(); | ||||
|         } | ||||
| 
 | ||||
|         public override Snowflake ReadJson( | ||||
|         public override Snowflake? ReadJson( | ||||
|             JsonReader reader, | ||||
|             Type objectType, | ||||
|             Snowflake existingValue, | ||||
|             Snowflake? existingValue, | ||||
|             bool hasExistingValue, | ||||
|             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 | ||||
|  |  | |||
|  | @ -16,6 +16,7 @@ | |||
| // ReSharper disable NotAccessedPositionalProperty.Global | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Utils; | ||||
| using Newtonsoft.Json; | ||||
| using Newtonsoft.Json.Linq; | ||||
| using NodaTime; | ||||
|  | @ -80,15 +81,17 @@ public record CreateReportRequest(ReportReason Reason, string? Context = null); | |||
| 
 | ||||
| public record IgnoreReportRequest(string? Reason = null); | ||||
| 
 | ||||
| public record WarnUserRequest( | ||||
|     string Reason, | ||||
|     FieldsToClear[]? ClearFields = null, | ||||
|     Snowflake? MemberId = null, | ||||
|     Snowflake? ReportId = null | ||||
| ); | ||||
| public class WarnUserRequest | ||||
| { | ||||
|     public required string Reason { get; init; } | ||||
|     public FieldsToClear[]? ClearFields { get; init; } | ||||
|     public Snowflake? MemberId { get; init; } | ||||
|     public Snowflake? ReportId { get; init; } | ||||
| } | ||||
| 
 | ||||
| public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null); | ||||
| 
 | ||||
| [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] | ||||
| public enum FieldsToClear | ||||
| { | ||||
|     DisplayName, | ||||
|  |  | |||
|  | @ -154,6 +154,12 @@ public class ModerationService( | |||
|         target.DeletedAt = clock.GetCurrentInstant(); | ||||
|         target.DeletedBy = moderator.Id; | ||||
| 
 | ||||
|         if (report != null) | ||||
|         { | ||||
|             report.Status = ReportStatus.Closed; | ||||
|             db.Update(report); | ||||
|         } | ||||
| 
 | ||||
|         if (!clearProfile) | ||||
|         { | ||||
|             db.Update(target); | ||||
|  | @ -334,6 +340,12 @@ public class ModerationService( | |||
|             db.Update(targetUser); | ||||
|         } | ||||
| 
 | ||||
|         if (report != null) | ||||
|         { | ||||
|             report.Status = ReportStatus.Closed; | ||||
|             db.Update(report); | ||||
|         } | ||||
| 
 | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         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) { | ||||
| 		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); | ||||
| 		else throw new ApiError(); | ||||
| 	} | ||||
|  |  | |||
|  | @ -71,6 +71,12 @@ export type PartialReport = { | |||
| 	target_type: "USER" | "MEMBER"; | ||||
| }; | ||||
| 
 | ||||
| export type ReportDetails = { | ||||
| 	report: Report; | ||||
| 	user: User; | ||||
| 	member?: Member; | ||||
| }; | ||||
| 
 | ||||
| export type QueriedUser = { | ||||
| 	user: User; | ||||
| 	member_list_hidden: boolean; | ||||
|  | @ -80,3 +86,28 @@ export type QueriedUser = { | |||
| 	deleted: boolean; | ||||
| 	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"> | ||||
| 	import { Icon } from "@sveltestrap/sveltestrap"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import type { RawApiError } from "$api/error"; | ||||
| 	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(); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -18,7 +18,7 @@ export const load = async ({ parent, fetch, cookies }) => { | |||
| 
 | ||||
| 	const reports = await apiRequest<Report[]>("GET", "/moderation/reports", { fetch, cookies }); | ||||
| 	const staleReportCount = reports.filter( | ||||
| 		(r) => idTimestamp(r.id).diffNow(["days"]).days >= 7, | ||||
| 		(r) => idTimestamp(r.id).diffNow(["days"]).days <= -7, | ||||
| 	).length; | ||||
| 
 | ||||
| 	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