feat(frontend): add username editing

This commit is contained in:
sam 2024-10-01 16:06:02 +02:00
parent 5a8b7aae80
commit 2a66e3e25e
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
10 changed files with 164 additions and 23 deletions

View file

@ -15,7 +15,6 @@ import {
useLoaderData,
ShouldRevalidateFunction,
useNavigate,
Navigate,
} from "@remix-run/react";
import { Trans, useTranslation } from "react-i18next";
import { Form, Button, Alert } from "react-bootstrap";

View file

@ -1,30 +1,94 @@
import { Table } from "react-bootstrap";
import { Button, Form, InputGroup, Table } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { Form as RemixForm, useActionData, useRouteLoaderData } from "@remix-run/react";
import { loader as settingsLoader } from "../settings/route";
import { LoaderFunctionArgs, json } from "@remix-run/node";
import serverRequest, { getToken } from "~/lib/request.server";
import { PartialMember } from "~/lib/api/user";
import { limits } from "~/env.server";
import { loader as rootLoader } from "../../root";
import { DateTime } from "luxon";
import { idTimestamp } from "~/lib/utils";
import { defaultAvatarUrl, idTimestamp } from "~/lib/utils";
import { ExclamationTriangleFill, InfoCircleFill } from "react-bootstrap-icons";
import AvatarImage from "~/components/profile/AvatarImage";
import { ActionFunctionArgs, json } from "@remix-run/node";
import { type ApiError, ErrorCode, firstErrorFor } from "~/lib/api/error";
import serverRequest, { getToken } from "~/lib/request.server";
import { MeUser } from "~/lib/api/user";
import ErrorAlert from "~/components/ErrorAlert";
export const loader = async ({ request }: LoaderFunctionArgs) => {
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await request.formData();
const username = data.get("username") as string | null;
const token = getToken(request);
const members = await serverRequest<PartialMember[]>("GET", "/users/@me/members", { token });
return json({ members, maxMemberCount: limits.member_count });
if (!username) {
return json({
error: {
status: 403,
code: ErrorCode.BadRequest,
message: "Invalid username",
} as ApiError,
user: null,
});
}
try {
const resp = await serverRequest<MeUser>("PATCH", "/users/@me", { body: { username }, token });
return json({ user: resp, error: null });
} catch (e) {
return json({ error: e as ApiError, user: null });
}
};
export default function SettingsIndex() {
const { members, maxMemberCount } = useLoaderData<typeof loader>();
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
const actionData = useActionData<typeof action>();
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
const { t } = useTranslation();
const createdAt = idTimestamp(user.id);
return (
<>
<Table striped bordered>
<div className="row">
<div className="col-md">
<RemixForm method="POST">
<Form as="div">
<Form.Group className="mb-3" controlId="username">
<Form.Label>{t("settings.general.username")}</Form.Label>
<InputGroup className="m-1 w-75">
<Form.Control
defaultValue={user.username}
id="username"
name="username"
type="text"
required
/>
<Button variant="secondary" type="submit">
{t("settings.general.change-username")}
</Button>
</InputGroup>
</Form.Group>
</Form>
</RemixForm>
<p className="text-muted text-has-newline">
<InfoCircleFill /> {t("settings.general.username-change-hint")}
</p>
{actionData?.error && <UsernameUpdateError error={actionData.error} />}
</div>
<div className="col-md text-center">
<AvatarImage
src={user.avatar_url || defaultAvatarUrl}
width={200}
alt={t("user.avatar-alt", { username: user.username })}
/>
</div>
</div>
<div>
<h4>{t("settings.general.log-out-everywhere")}</h4>
<p></p>
</div>
<h4>{t("settings.general.table-header")}</h4>
<Table striped bordered hover>
<tbody>
<tr>
<th scope="row">{t("settings.general.id")}</th>
@ -39,7 +103,23 @@ export default function SettingsIndex() {
<tr>
<th scope="row">{t("settings.general.member-count")}</th>
<td>
{members.length}/{maxMemberCount}
{user.members.length}/{meta.limits.member_count}
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.member-list-hidden")}</th>
<td>{user.member_list_hidden ? t("yes") : t("no")}</td>
</tr>
<tr>
<th scope="row">{t("settings.general.custom-preferences")}</th>
<td>
{Object.keys(user.custom_preferences).length}/{meta.limits.custom_preferences}
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.role")}</th>
<td>
<code>{user.role}</code>
</td>
</tr>
</tbody>
@ -47,3 +127,18 @@ export default function SettingsIndex() {
</>
);
}
function UsernameUpdateError({ error }: { error: ApiError }) {
const { t } = useTranslation();
const usernameError = firstErrorFor(error, "username");
if (!usernameError) {
return <ErrorAlert error={error} />;
}
return (
<p className="text-danger-emphasis text-has-newline">
<ExclamationTriangleFill />{" "}
{t("settings.general.username-update-error", { message: usernameError.message })}
</p>
);
}

View file

@ -1,7 +1,7 @@
import { LoaderFunctionArgs, json, redirect, MetaFunction } from "@remix-run/node";
import i18n from "~/i18next.server";
import serverRequest, { getToken } from "~/lib/request.server";
import { User } from "~/lib/api/user";
import { MeUser } from "~/lib/api/user";
import { Link, Outlet, useLocation } from "@remix-run/react";
import { Nav } from "react-bootstrap";
import { useTranslation } from "react-i18next";
@ -16,7 +16,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
if (token) {
try {
const user = await serverRequest<User>("GET", "/users/@me", { token });
const user = await serverRequest<MeUser>("GET", "/users/@me", { token });
return json({ user, meta: { title: t("settings.title") } });
} catch (e) {
return redirect("/auth/log-in");