feat(frontend): report profile page
This commit is contained in:
		
							parent
							
								
									05913a3b2f
								
							
						
					
					
						commit
						bd21eeebcf
					
				
					 9 changed files with 268 additions and 12 deletions
				
			
		|  | @ -9,7 +9,7 @@ export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; | |||
| /** | ||||
|  * Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`. | ||||
|  */ | ||||
| export type RequestArgs = { | ||||
| export type RequestArgs<T> = { | ||||
| 	/** | ||||
| 	 * The token for this request. Where possible, `cookies` should be passed instead. | ||||
| 	 * Will override `cookies` if both are passed. | ||||
|  | @ -23,7 +23,7 @@ export type RequestArgs = { | |||
| 	/** | ||||
| 	 * The body for this request, which will be serialized to JSON. Should be a plain JS object. | ||||
| 	 */ | ||||
| 	body?: unknown; | ||||
| 	body?: T; | ||||
| 	/** | ||||
| 	 * The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests. | ||||
| 	 */ | ||||
|  | @ -41,10 +41,10 @@ export type RequestArgs = { | |||
|  * @param args Optional arguments to the request function. | ||||
|  * @returns A Response object. | ||||
|  */ | ||||
| export async function baseRequest( | ||||
| export async function baseRequest<T = unknown>( | ||||
| 	method: Method, | ||||
| 	path: string, | ||||
| 	args: RequestArgs = {}, | ||||
| 	args: RequestArgs<T> = {}, | ||||
| ): Promise<Response> { | ||||
| 	const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME); | ||||
| 
 | ||||
|  | @ -72,11 +72,11 @@ export async function baseRequest( | |||
|  * @param args Optional arguments to the request function. | ||||
|  * @returns The response deserialized as `T`. | ||||
|  */ | ||||
| export async function apiRequest<T>( | ||||
| export async function apiRequest<TResponse, TRequest = unknown>( | ||||
| 	method: Method, | ||||
| 	path: string, | ||||
| 	args: RequestArgs = {}, | ||||
| ): Promise<T> { | ||||
| 	args: RequestArgs<TRequest> = {}, | ||||
| ): Promise<TResponse> { | ||||
| 	const resp = await baseRequest(method, path, args); | ||||
| 
 | ||||
| 	if (resp.status < 200 || resp.status > 299) { | ||||
|  | @ -84,7 +84,7 @@ export async function apiRequest<T>( | |||
| 		if ("code" in err) throw new ApiError(err); | ||||
| 		else throw new ApiError(); | ||||
| 	} | ||||
| 	return (await resp.json()) as T; | ||||
| 	return (await resp.json()) as TResponse; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | @ -94,10 +94,10 @@ export async function apiRequest<T>( | |||
|  * @param args Optional arguments to the request function. | ||||
|  * @param enforce204 Whether to throw an error on a non-204 status code. | ||||
|  */ | ||||
| export async function fastRequest( | ||||
| export async function fastRequest<T = unknown>( | ||||
| 	method: Method, | ||||
| 	path: string, | ||||
| 	args: RequestArgs = {}, | ||||
| 	args: RequestArgs<T> = {}, | ||||
| 	enforce204: boolean = false, | ||||
| ): Promise<void> { | ||||
| 	const resp = await baseRequest(method, path, args); | ||||
|  |  | |||
							
								
								
									
										26
									
								
								Foxnouns.Frontend/src/lib/api/models/moderation.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								Foxnouns.Frontend/src/lib/api/models/moderation.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| export type CreateReportRequest = { | ||||
| 	reason: ReportReason; | ||||
| 	context: string | null; | ||||
| }; | ||||
| 
 | ||||
| export enum ReportReason { | ||||
| 	Totalitarianism = "TOTALITARIANISM", | ||||
| 	HateSpeech = "HATE_SPEECH", | ||||
| 	Racism = "RACISM", | ||||
| 	Homophobia = "HOMOPHOBIA", | ||||
| 	Transphobia = "TRANSPHOBIA", | ||||
| 	Queerphobia = "QUEERPHOBIA", | ||||
| 	Exclusionism = "EXCLUSIONISM", | ||||
| 	Sexism = "SEXISM", | ||||
| 	Ableism = "ABLEISM", | ||||
| 	ChildPornography = "CHILD_PORNOGRAPHY", | ||||
| 	PedophiliaAdvocacy = "PEDOPHILIA_ADVOCACY", | ||||
| 	Harassment = "HARASSMENT", | ||||
| 	Impersonation = "IMPERSONATION", | ||||
| 	Doxxing = "DOXXING", | ||||
| 	EncouragingSelfHarm = "ENCOURAGING_SELF_HARM", | ||||
| 	Spam = "SPAM", | ||||
| 	Trolling = "TROLLING", | ||||
| 	Advertisement = "ADVERTISEMENT", | ||||
| 	CopyrightViolation = "COPYRIGHT_VIOLATION", | ||||
| } | ||||
|  | @ -0,0 +1,12 @@ | |||
| <script lang="ts"> | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 
 | ||||
| 	type Props = { required?: boolean }; | ||||
| 	let { required }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| {#if required} | ||||
| 	<small class="text-danger"><abbr title={$t("form.required")}>*</abbr></small> | ||||
| {:else} | ||||
| 	<small class="text-body-secondary">{$t("form.optional")}</small> | ||||
| {/if} | ||||
|  | @ -0,0 +1,36 @@ | |||
| <script lang="ts"> | ||||
| 	import type { MeUser } from "$api/models"; | ||||
| 	import { PUBLIC_BASE_URL, PUBLIC_SHORT_URL } from "$env/static/public"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		user: string; | ||||
| 		member?: string; | ||||
| 		sid: string; | ||||
| 		reportUrl: string; | ||||
| 		meUser: MeUser | null; | ||||
| 	}; | ||||
| 
 | ||||
| 	let { user, member, sid, reportUrl, meUser }: Props = $props(); | ||||
| 
 | ||||
| 	let profileUrl = $derived( | ||||
| 		member ? `${PUBLIC_BASE_URL}/@${user}/${member}` : `${PUBLIC_BASE_URL}/@${user}`, | ||||
| 	); | ||||
| 	let shortUrl = $derived(`${PUBLIC_SHORT_URL}/${sid}`); | ||||
| 
 | ||||
| 	const copyUrl = async (url: string) => { | ||||
| 		await navigator.clipboard.writeText(url); | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <div class="btn-group"> | ||||
| 	<button type="button" class="btn btn-outline-secondary" onclick={() => copyUrl(profileUrl)}> | ||||
| 		{$t("profile.copy-link-button")} | ||||
| 	</button> | ||||
| 	<button type="button" class="btn btn-outline-secondary" onclick={() => copyUrl(shortUrl)}> | ||||
| 		{$t("profile.copy-short-link-button")} | ||||
| 	</button> | ||||
| 	{#if meUser && meUser.username !== user} | ||||
| 		<a class="btn btn-outline-danger" href={reportUrl}>{$t("profile.report-button")}</a> | ||||
| 	{/if} | ||||
| </div> | ||||
|  | @ -18,7 +18,10 @@ | |||
| 		"pronouns-header": "Pronouns", | ||||
| 		"default-members-header": "Members", | ||||
| 		"create-member-button": "Create member", | ||||
| 		"back-to-user": "Back to {{name}}" | ||||
| 		"back-to-user": "Back to {{name}}", | ||||
| 		"copy-link-button": "Copy link", | ||||
| 		"copy-short-link-button": "Copy short link", | ||||
| 		"report-button": "Report profile" | ||||
| 	}, | ||||
| 	"title": { | ||||
| 		"log-in": "Log in", | ||||
|  | @ -237,5 +240,35 @@ | |||
| 		"custom-preference-muted": "Show as muted text", | ||||
| 		"custom-preference-favourite": "Treat like favourite" | ||||
| 	}, | ||||
| 	"cancel": "Cancel" | ||||
| 	"cancel": "Cancel", | ||||
| 	"report": { | ||||
| 		"title": "Reporting {{name}}", | ||||
| 		"totalitarianism": "Support of totalitarian regimes", | ||||
| 		"hate-speech": "Hate speech", | ||||
| 		"racism": "Racism or xenophobia", | ||||
| 		"homophobia": "Homophobia", | ||||
| 		"transphobia": "Transphobia", | ||||
| 		"queerphobia": "Queerphobia (other)", | ||||
| 		"exclusionism": "Queer or plural exclusionism", | ||||
| 		"sexism": "Sexism or misogyny", | ||||
| 		"ableism": "Ableism", | ||||
| 		"child-pornography": "Child pornography", | ||||
| 		"pedophilia-advocacy": "Pedophilia advocacy", | ||||
| 		"harassment": "Harassment", | ||||
| 		"impersonation": "Impersonation", | ||||
| 		"doxxing": "Doxxing", | ||||
| 		"encouraging-self-harm": "Encouraging self-harm or suicide", | ||||
| 		"spam": "Spam", | ||||
| 		"trolling": "Trolling", | ||||
| 		"advertisement": "Advertising", | ||||
| 		"copyright-violation": "Copyright or trademark violation", | ||||
| 		"success": "Successfully submitted report!", | ||||
| 		"reason-label": "Why are you reporting this profile?", | ||||
| 		"context-label": "Is there any context you'd like to give us?", | ||||
| 		"submit-button": "Submit report" | ||||
| 	}, | ||||
| 	"form": { | ||||
| 		"optional": "(optional)", | ||||
| 		"required": "Required" | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ | |||
| 	import { Icon } from "@sveltestrap/sveltestrap"; | ||||
| 	import Paginator from "$components/Paginator.svelte"; | ||||
| 	import MemberCard from "$components/profile/user/MemberCard.svelte"; | ||||
| 	import ProfileButtons from "$components/profile/ProfileButtons.svelte"; | ||||
| 
 | ||||
| 	type Props = { data: PageData }; | ||||
| 	let { data }: Props = $props(); | ||||
|  | @ -28,6 +29,13 @@ | |||
| 	<ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} /> | ||||
| 	<ProfileFields profile={data.user} {allPreferences} /> | ||||
| 
 | ||||
| 	<ProfileButtons | ||||
| 		meUser={data.meUser} | ||||
| 		user={data.user.username} | ||||
| 		sid={data.user.sid} | ||||
| 		reportUrl="/report/{data.user.id}" | ||||
| 	/> | ||||
| 
 | ||||
| 	{#if data.members.length > 0} | ||||
| 		<hr /> | ||||
| 		<h2> | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ | |||
| 	import ProfileFields from "$components/profile/ProfileFields.svelte"; | ||||
| 	import { Icon } from "@sveltestrap/sveltestrap"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import ProfileButtons from "$components/profile/ProfileButtons.svelte"; | ||||
| 
 | ||||
| 	type Props = { data: PageData }; | ||||
| 	let { data }: Props = $props(); | ||||
|  | @ -37,4 +38,12 @@ | |||
| 
 | ||||
| 	<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} /> | ||||
| 	<ProfileFields profile={data.member} {allPreferences} /> | ||||
| 
 | ||||
| 	<ProfileButtons | ||||
| 		meUser={data.meUser} | ||||
| 		user={data.member.user.username} | ||||
| 		member={data.member.name} | ||||
| 		sid={data.member.sid} | ||||
| 		reportUrl="/report/{data.member.user.id}?member={data.member.id}" | ||||
| 	/> | ||||
| </div> | ||||
|  |  | |||
							
								
								
									
										60
									
								
								Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,60 @@ | |||
| import { apiRequest, fastRequest } from "$api"; | ||||
| import ApiError from "$api/error.js"; | ||||
| import type { Member } from "$api/models/member.js"; | ||||
| import { type CreateReportRequest, ReportReason } from "$api/models/moderation.js"; | ||||
| import type { PartialUser, User } from "$api/models/user.js"; | ||||
| import log from "$lib/log.js"; | ||||
| import { redirect } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export const load = async ({ parent, params, fetch, cookies, url }) => { | ||||
| 	const { meUser } = await parent(); | ||||
| 	if (!meUser) redirect(303, "/"); | ||||
| 
 | ||||
| 	let user: PartialUser; | ||||
| 	let member: Member | null = null; | ||||
| 	if (url.searchParams.has("member")) { | ||||
| 		const resp = await apiRequest<Member>( | ||||
| 			"GET", | ||||
| 			`/users/${params.id}/members/${url.searchParams.get("member")}`, | ||||
| 			{ fetch, cookies }, | ||||
| 		); | ||||
| 
 | ||||
| 		user = resp.user; | ||||
| 		member = resp; | ||||
| 	} else { | ||||
| 		user = await apiRequest<User>("GET", `/users/${params.id}`, { fetch, cookies }); | ||||
| 	} | ||||
| 
 | ||||
| 	if (meUser.id === user.id) redirect(303, "/"); | ||||
| 
 | ||||
| 	return { user, member }; | ||||
| }; | ||||
| 
 | ||||
| export const actions = { | ||||
| 	default: async ({ request, fetch, cookies }) => { | ||||
| 		const body = await request.formData(); | ||||
| 
 | ||||
| 		const targetIsMember = body.get("target-type") === "member"; | ||||
| 		const target = body.get("target-id") as string; | ||||
| 		const reason = body.get("reason") as ReportReason; | ||||
| 		const context = body.get("context") as string | null; | ||||
| 
 | ||||
| 		const url = targetIsMember | ||||
| 			? `/moderation/report-member/${target}` | ||||
| 			: `/moderation/report-user/${target}`; | ||||
| 
 | ||||
| 		try { | ||||
| 			await fastRequest<CreateReportRequest>("POST", url, { | ||||
| 				body: { reason, context }, | ||||
| 				fetch, | ||||
| 				cookies, | ||||
| 			}); | ||||
| 
 | ||||
| 			return { ok: true, error: null }; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError) return { ok: false, error: e.obj }; | ||||
| 			log.error("error reporting user or member %s:", target, e); | ||||
| 			throw e; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
							
								
								
									
										72
									
								
								Foxnouns.Frontend/src/routes/report/[id]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								Foxnouns.Frontend/src/routes/report/[id]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,72 @@ | |||
| <script lang="ts"> | ||||
| 	import { ReportReason } from "$api/models/moderation"; | ||||
| 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||
| 	import RequiredFieldMarker from "$components/RequiredFieldMarker.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import type { ActionData, PageData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
| 
 | ||||
| 	let name = $derived( | ||||
| 		data.member ? `${data.member.name} (@${data.user.username})` : "@" + data.user.username, | ||||
| 	); | ||||
| 
 | ||||
| 	let link = $derived( | ||||
| 		data.member ? `/@${data.user.username}/${data.member.name}` : `/@${data.user.username}`, | ||||
| 	); | ||||
| 
 | ||||
| 	console.log(data.user, !!data.member); | ||||
| 
 | ||||
| 	let reasons = $derived.by(() => { | ||||
| 		const reasons = []; | ||||
| 		for (const value of Object.values(ReportReason)) { | ||||
| 			const key = "report." + value.toLowerCase().replaceAll("_", "-"); | ||||
| 			reasons.push({ key, value }); | ||||
| 		} | ||||
| 		return reasons; | ||||
| 	}); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>{$t("report.title", { name })} • pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	<form method="POST" class="w-lg-75 mx-auto"> | ||||
| 		<h3>{$t("report.title", { name })}</h3> | ||||
| 		<FormStatusMarker {form} successMessage={$t("report.success")} /> | ||||
| 		<input type="hidden" name="target-type" value={data.member ? "member" : "user"} /> | ||||
| 		<input type="hidden" name="target-id" value={data.member ? data.member.id : data.user.id} /> | ||||
| 
 | ||||
| 		<h4 class="mt-3">{$t("report.reason-label")} <RequiredFieldMarker required /></h4> | ||||
| 		<div class="row row-cols-1 row-cols-lg-2"> | ||||
| 			{#each reasons as reason} | ||||
| 				<div class="col"> | ||||
| 					<div class="form-check"> | ||||
| 						<input | ||||
| 							class="form-check-input" | ||||
| 							type="radio" | ||||
| 							name="reason" | ||||
| 							value={reason.value} | ||||
| 							id="reason-{reason.value}" | ||||
| 							required | ||||
| 						/> | ||||
| 						<label class="form-check-label" for="reason-{reason.value}">{$t(reason.key)}</label> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			{/each} | ||||
| 		</div> | ||||
| 
 | ||||
| 		<h4 class="mt-3"> | ||||
| 			{$t("report.context-label")} | ||||
| 			<RequiredFieldMarker /> | ||||
| 		</h4> | ||||
| 		<textarea class="form-control" name="context" style="height: 100px;" maxlength={512}></textarea> | ||||
| 
 | ||||
| 		<div class="mt-3"> | ||||
| 			<button type="submit" class="btn btn-danger">{$t("report.submit-button")}</button> | ||||
| 			<a href={link} class="btn btn-secondary">{$t("cancel")}</a> | ||||
| 		</div> | ||||
| 	</form> | ||||
| </div> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue