v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s

This commit is contained in:
khannurien
2026-06-26 22:20:07 +00:00
parent 26f5abfa4e
commit 73e0114bf7
30 changed files with 2369 additions and 2120 deletions

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import type { SubmitEvent } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -8,77 +7,48 @@ import { API_URL } from "../config/api.ts";
import {
deserializeAuthResponse,
type LoginRequest,
parseAPIResponse,
type RawAuthResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
type LoginState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
interface LoginValues {
username: string;
password: string;
}
type ResetState =
| { status: "idle" }
| { status: "submitting" }
| { status: "sent" }
| { status: "error"; error: string };
interface ResetValues {
email: string;
}
export function UserLogin() {
const navigate = useNavigate();
const { login } = useAuth();
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState("");
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setLoginState({ status: "submitting" });
const form = useApiForm<LoginValues>({
defaultValues: { username: "", password: "" },
});
const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const res = await fetch(`${API_URL}/api/users/login`, {
const onSubmit = form.submit(async ({ username, password }) => {
const data = await expectOk<RawAuthResponse>(
await fetch(`${API_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password } satisfies LoginRequest),
});
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setLoginState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setLoginState({ status: "error", error: friendlyFetchError(err) });
}
};
const handleResetRequest = async (e: React.SubmitEvent) => {
e.preventDefault();
if (!resetEmail.trim()) return;
setResetState({ status: "submitting" });
try {
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: resetEmail.trim() }),
});
setResetState({ status: "sent" });
} catch {
setResetState({ status: "error", error: t`Could not connect to server` });
}
};
}),
);
login(deserializeAuthResponse(data));
navigate("/");
});
return (
<PageShell centered>
@@ -87,98 +57,38 @@ export function UserLogin() {
<Trans>Log in</Trans>
</h1>
{loginState.status === "error" && (
<ErrorCard title={t`Login failed`} message={loginState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder={t`Username`}
required
disabled={loginState.status === "submitting"}
autoFocus
/>
<input
name="password"
type="password"
placeholder={t`Password`}
required
disabled={loginState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={loginState.status === "submitting"}
>
{loginState.status === "submitting"
? <Trans>Logging in…</Trans>
: <Trans>Log in</Trans>}
</button>
</form>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Login failed`} />
<TextField<LoginValues>
name="username"
placeholder={t`Username`}
autoFocus
rules={{ required: true }}
/>
<TextField<LoginValues>
name="password"
type="password"
placeholder={t`Password`}
rules={{ required: true }}
/>
<SubmitButton pendingLabel={<Trans>Logging in…</Trans>}>
<Trans>Log in</Trans>
</SubmitButton>
</form>
</FormProvider>
<p className="auth-card-footer">
<button
type="button"
className="auth-link-btn"
onClick={() => {
setShowReset((v) => !v);
setResetState({ status: "idle" });
setResetEmail("");
}}
onClick={() => setShowReset((v) => !v)}
>
<Trans>Forgot password?</Trans>
</button>
</p>
{showReset && (
<div className="auth-reset-panel">
{resetState.status === "sent"
? (
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link
shortly.
</Trans>
</p>
)
: (
<>
{resetState.status === "error" && (
<ErrorCard
title={t`Request failed`}
message={resetState.error}
/>
)}
<form
onSubmit={handleResetRequest}
className="auth-form auth-reset-form"
>
<input
type="email"
placeholder={t`Your email address`}
value={resetEmail}
onChange={(e) => setResetEmail(e.target.value)}
required
autoFocus
disabled={resetState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={resetState.status === "submitting" ||
!resetEmail.trim()}
>
{resetState.status === "submitting"
? <Trans>Sending…</Trans>
: <Trans>Send reset link</Trans>}
</button>
</form>
</>
)}
</div>
)}
{showReset && <ResetRequestPanel />}
<p className="auth-card-footer">
<Trans>This is a mirage.</Trans>
@@ -187,3 +97,49 @@ export function UserLogin() {
</PageShell>
);
}
function ResetRequestPanel() {
const [sent, setSent] = useState(false);
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
const onSubmit = form.submit(async ({ email }) => {
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
});
setSent(true);
});
if (sent) {
return (
<div className="auth-reset-panel">
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link shortly.
</Trans>
</p>
</div>
);
}
return (
<div className="auth-reset-panel">
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form auth-reset-form">
<FormError title={t`Request failed`} />
<TextField<ResetValues>
name="email"
type="email"
placeholder={t`Your email address`}
autoFocus
rules={{ required: true }}
/>
<SubmitButton pendingLabel={<Trans>Sending…</Trans>}>
<Trans>Send reset link</Trans>
</SubmitButton>
</form>
</FormProvider>
</div>
);
}