UI: unified design system, light/dark themes, redesigned pages and player UI

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>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 03:43:53 +03:00
1 parent 51a7e77e56
commit 9ee3e1ff6e
27 files changed
+2399 -508

No files matched your search

+51 -30
View File
@@ -1,16 +1,22 @@
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 [done, setDone] = useState(false);
const mismatch = repeat.length > 0 && password !== repeat;
const submit = async (e: FormEvent) => {
e.preventDefault();
@@ -26,7 +32,7 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
new_username: username.trim() || null,
});
setMe(updated);
setDone(true);
if (!forced) ui.toast("Данные сохранены", "ok");
setCurrent("");
setPassword("");
setRepeat("");
@@ -36,39 +42,54 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
};
const form = (
<form className="card auth-card" onSubmit={submit}>
<h1>{forced ? "Смена учётных данных" : "Аккаунт"}</h1>
{forced && (
<p className="muted">
{needUsername
? "Это первый вход администратора. Задайте собственный логин и пароль."
: "Пароль был выдан администратором. Задайте свой пароль."}
</p>
)}
<label>
Логин{needUsername && " (новый)"}
<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>
Текущий пароль
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password" />
<label className="field">
<span>Текущий пароль</span>
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" />
</label>
<label>
Новый пароль (от 8 символов)
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password" />
<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>
Повторите пароль
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)}
autoComplete="new-password" />
<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 && <div className="error">{error}</div>}
{done && !forced && <div className="ok">Сохранено</div>}
<button className="primary" disabled={!current || password.length < 8}>Сохранить</button>
{forced && <button type="button" className="link" onClick={() => void logout()}>Выйти</button>}
{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>
);
return forced ? <div className="center">{form}</div> : <div className="page narrow">{form}</div>;
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>
);
}