Tokens and components in styles.css, shared Ui (dialogs, toasts), icons, bottom tab bar on mobile, keyboard/gamepad navigation, library shelf with filters and conversion badges, hero game page, restyled player screens. Games list now returns per-game version counts. Adds docs/UI_DESIGN.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
96 lines
3.9 KiB
TypeScript
96 lines
3.9 KiB
TypeScript
import { useState, type FormEvent } from "react";
|
|
import { api, type Me } from "../api";
|
|
import { useAuth } from "../auth";
|
|
import { ErrorNote, PageHeader } from "../ui/Bits";
|
|
import { Icon } from "../ui/Icon";
|
|
import { useUi } from "../ui/Ui";
|
|
import { ThemeButton } from "../ui/ThemeButton";
|
|
|
|
export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
|
|
const { me, setMe, logout } = useAuth();
|
|
const ui = useUi();
|
|
const needUsername = !!me?.must_change_username;
|
|
const [username, setUsername] = useState(needUsername ? "" : me?.username ?? "");
|
|
const [current, setCurrent] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [repeat, setRepeat] = useState("");
|
|
const [error, setError] = useState("");
|
|
|
|
const mismatch = repeat.length > 0 && password !== repeat;
|
|
|
|
const submit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
if (password !== repeat) {
|
|
setError("Пароли не совпадают");
|
|
return;
|
|
}
|
|
try {
|
|
const updated = await api.post<Me>("/api/auth/change-credentials", {
|
|
current_password: current,
|
|
new_password: password,
|
|
new_username: username.trim() || null,
|
|
});
|
|
setMe(updated);
|
|
if (!forced) ui.toast("Данные сохранены", "ok");
|
|
setCurrent("");
|
|
setPassword("");
|
|
setRepeat("");
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
}
|
|
};
|
|
|
|
const form = (
|
|
<form className={forced ? "card auth-card" : "card form"} onSubmit={submit}>
|
|
{forced ? (
|
|
<div>
|
|
<div className="brand"><span className="brand-mark"><Icon name="key" size={20} /></span>Смена данных</div>
|
|
<p className="muted" style={{ marginTop: "0.6rem" }}>
|
|
{needUsername
|
|
? "Это первый вход администратора. Задайте собственный логин и пароль."
|
|
: "Пароль был выдан администратором. Задайте свой пароль."}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
<label className="field">
|
|
<span>Логин{needUsername && " (новый)"}</span>
|
|
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
|
</label>
|
|
<label className="field">
|
|
<span>Текущий пароль</span>
|
|
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" />
|
|
</label>
|
|
<label className="field">
|
|
<span>Новый пароль</span>
|
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
|
<span className="hint">Минимум 8 символов</span>
|
|
</label>
|
|
<label className="field">
|
|
<span>Повторите пароль</span>
|
|
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)} autoComplete="new-password"
|
|
aria-invalid={mismatch} style={mismatch ? { borderColor: "var(--danger)" } : undefined} />
|
|
{mismatch && <span className="hint" style={{ color: "var(--danger)" }}>Пароли не совпадают</span>}
|
|
</label>
|
|
{error && <ErrorNote>{error}</ErrorNote>}
|
|
<button className="btn btn-primary btn-lg" disabled={!current || password.length < 8 || mismatch}>Сохранить</button>
|
|
{forced && <button type="button" className="btn btn-ghost" onClick={() => void logout()}>Выйти</button>}
|
|
</form>
|
|
);
|
|
|
|
if (forced) {
|
|
return (
|
|
<div className="auth-wrap">
|
|
<ThemeButton className="auth-theme" />
|
|
{form}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="page narrow">
|
|
<PageHeader title="Аккаунт" subtitle={<>Вы вошли как <b>{me?.username}</b> · {me?.role.name}</>} />
|
|
{form}
|
|
</div>
|
|
);
|
|
}
|