diff --git a/Foxnouns.Frontend/src/lib/api/index.ts b/Foxnouns.Frontend/src/lib/api/index.ts index 0a4047d..a23b68c 100644 --- a/Foxnouns.Frontend/src/lib/api/index.ts +++ b/Foxnouns.Frontend/src/lib/api/index.ts @@ -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 = { /** * 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( method: Method, path: string, - args: RequestArgs = {}, + args: RequestArgs = {}, ): Promise { 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( +export async function apiRequest( method: Method, path: string, - args: RequestArgs = {}, -): Promise { + args: RequestArgs = {}, +): Promise { const resp = await baseRequest(method, path, args); if (resp.status < 200 || resp.status > 299) { @@ -84,7 +84,7 @@ export async function apiRequest( 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( * @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( method: Method, path: string, - args: RequestArgs = {}, + args: RequestArgs = {}, enforce204: boolean = false, ): Promise { const resp = await baseRequest(method, path, args); diff --git a/Foxnouns.Frontend/src/lib/api/models/moderation.ts b/Foxnouns.Frontend/src/lib/api/models/moderation.ts new file mode 100644 index 0000000..b95da5c --- /dev/null +++ b/Foxnouns.Frontend/src/lib/api/models/moderation.ts @@ -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", +} diff --git a/Foxnouns.Frontend/src/lib/components/RequiredFieldMarker.svelte b/Foxnouns.Frontend/src/lib/components/RequiredFieldMarker.svelte new file mode 100644 index 0000000..f67d9ef --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/RequiredFieldMarker.svelte @@ -0,0 +1,12 @@ + + +{#if required} + * +{:else} + {$t("form.optional")} +{/if} diff --git a/Foxnouns.Frontend/src/lib/components/profile/ProfileButtons.svelte b/Foxnouns.Frontend/src/lib/components/profile/ProfileButtons.svelte new file mode 100644 index 0000000..d3de215 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/profile/ProfileButtons.svelte @@ -0,0 +1,36 @@ + + +
+ + + {#if meUser && meUser.username !== user} + {$t("profile.report-button")} + {/if} +
diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 2ede9a1..16f5527 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -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" + } } diff --git a/Foxnouns.Frontend/src/routes/@[username]/+page.svelte b/Foxnouns.Frontend/src/routes/@[username]/+page.svelte index cefd8bc..0562274 100644 --- a/Foxnouns.Frontend/src/routes/@[username]/+page.svelte +++ b/Foxnouns.Frontend/src/routes/@[username]/+page.svelte @@ -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 @@ + + {#if data.members.length > 0}

diff --git a/Foxnouns.Frontend/src/routes/@[username]/[memberName]/+page.svelte b/Foxnouns.Frontend/src/routes/@[username]/[memberName]/+page.svelte index a69544a..e0deff6 100644 --- a/Foxnouns.Frontend/src/routes/@[username]/[memberName]/+page.svelte +++ b/Foxnouns.Frontend/src/routes/@[username]/[memberName]/+page.svelte @@ -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 @@ + + diff --git a/Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts b/Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts new file mode 100644 index 0000000..5d36696 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/report/[id]/+page.server.ts @@ -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( + "GET", + `/users/${params.id}/members/${url.searchParams.get("member")}`, + { fetch, cookies }, + ); + + user = resp.user; + member = resp; + } else { + user = await apiRequest("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("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; + } + }, +}; diff --git a/Foxnouns.Frontend/src/routes/report/[id]/+page.svelte b/Foxnouns.Frontend/src/routes/report/[id]/+page.svelte new file mode 100644 index 0000000..24458ab --- /dev/null +++ b/Foxnouns.Frontend/src/routes/report/[id]/+page.svelte @@ -0,0 +1,72 @@ + + + + {$t("report.title", { name })} • pronouns.cc + + +
+
+

{$t("report.title", { name })}

+ + + + +

{$t("report.reason-label")}

+
+ {#each reasons as reason} +
+
+ + +
+
+ {/each} +
+ +

+ {$t("report.context-label")} + +

+ + +
+ + {$t("cancel")} +
+ +