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…
Reference in a new issue