feat(frontend): full redesign of login/register

Owner's complaint: the two-pane layout's decorative right-side panel
("Твои настройки LoVisual на любом компьютере...") made no sense next
to a login form. Dropped the whole two-pane concept, not just its
visuals.

Login/register now render as the mod's own in-game account screen:
night-lake backdrop (the same Backdrop the landing hero uses), a
clock + Вход/Регистрация tab switcher styled like the mod's own
ClickGui tabs, a single glass panel on the mod's own category
colours, and a real mod-style hint row ("X - Y") instead of marketing
copy. What an account actually gets you is stated as plain hints
(4 cloud slots, share a slot's code, the mod signs itself in by code,
no account required to just use the mod) instead of a sales pitch.

- AuthSplit.tsx removed; AuthScreen.tsx + auth.css replace it
- LoginForm/RegisterForm: dropped the redundant login<->register text
  link now that the tab switcher does that job; RegisterForm now
  honours the original `from` redirect (previously always went to
  /configs even when registration started from /link)
- Rewrote auth i18n copy to match the site's established voice

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
loki5512344 2026-09-30 10:30:53 +02:00
parent c2c7e8fa91
commit 756ebc6d86
Signed by: boba
GPG key ID: 253067914055423B
10 changed files with 366 additions and 74 deletions

View file

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router'
import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'
@ -52,12 +52,6 @@ export function LoginForm() {
<Button type="submit" busy={busy}>
{t('login.submit')}
</Button>
<p className="text-sm text-frost">
{t('login.noAccount')}{' '}
<Link to="/register" className="text-ice underline-offset-4 hover:underline">
{t('login.createAccount')}
</Link>
</p>
</form>
)
}

View file

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router'
import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'
@ -13,6 +13,7 @@ export function RegisterForm() {
const { t: tc } = useTranslation('common')
const { signUp } = useSession()
const navigate = useNavigate()
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
const [email, setEmail] = useState('')
const [nick, setNick] = useState('')
const [password, setPassword] = useState('')
@ -37,7 +38,7 @@ export function RegisterForm() {
setBusy(true)
try {
await signUp(email.trim(), password, nick.trim())
navigate('/configs', { replace: true })
navigate(from, { replace: true })
} catch (err) {
setError(describeError(tc, err, { 409: t('register.emailTaken') }))
} finally {
@ -76,12 +77,6 @@ export function RegisterForm() {
<Button type="submit" busy={busy}>
{t('register.submit')}
</Button>
<p className="text-sm text-frost">
{t('register.haveAccount')}{' '}
<Link to="/login" className="text-ice underline-offset-4 hover:underline">
{t('register.login')}
</Link>
</p>
</form>
)
}

View file

@ -3,28 +3,45 @@ import type { authRu } from './ru'
export const authEn = {
checkingSession: 'Checking session…',
splitPitch: 'Your LoVisual setup, on any computer: cloud configs, share codes for friends, and a showcase of ready-made builds.',
tabs: {
label: 'Log in or sign up',
login: 'Log in',
register: 'Sign up',
},
hintsLabel: 'Hints',
next: {
link: 'Next: you confirm the code from the game.',
configs: 'Next: your cloud slots.',
account: 'Next: your account settings.',
showcase: 'Next: the showcase config goes into your slot.',
},
login: {
title: 'Log in',
title: 'Welcome back',
emailLabel: 'Email',
passwordLabel: 'Password',
submit: 'Log in',
noAccount: "Don't have an account?",
createAccount: 'Sign up',
wrongCredentials: 'Wrong email or password.',
hints: {
one: 'No account - the "Sign up" tab up top',
two: 'The password is for the site only',
three: 'The mod signs in by itself, with a code from its Account tab',
},
},
register: {
title: 'New account',
subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.",
emailLabel: 'Email',
nickLabel: 'Site nickname',
nickHint: "Doesn't have to match your Minecraft nick.",
passwordLabel: 'Password',
passwordHint: 'At least 8 characters.',
submit: 'Create account',
haveAccount: 'Already have an account?',
login: 'Log in',
emailTaken: 'That email is already taken. Log in or use another one.',
hints: {
one: 'Account - 4 cloud slots for your configs',
two: 'Slot code - send it to a friend or load it on another PC',
three: 'No password in the mod, it signs in with a code',
four: 'The mod works without an account too',
},
},
validation: {
emailRequired: 'Enter your email.',

View file

@ -1,27 +1,44 @@
export const authRu = {
checkingSession: 'Проверяем вход…',
splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.',
tabs: {
label: 'Вход или регистрация',
login: 'Вход',
register: 'Регистрация',
},
hintsLabel: 'Подсказки',
next: {
link: 'Дальше: подтвердишь код из игры.',
configs: 'Дальше: твои облачные слоты.',
account: 'Дальше: настройки аккаунта.',
showcase: 'Дальше: заберёшь конфиг с витрины в свой слот.',
},
login: {
title: 'Вход',
title: 'С возвращением',
emailLabel: 'Почта',
passwordLabel: 'Пароль',
submit: 'Войти',
noAccount: 'Нет аккаунта?',
createAccount: 'Создать',
wrongCredentials: 'Неверная почта или пароль.',
hints: {
one: 'Нет аккаунта - вкладка «Регистрация» наверху',
two: 'Пароль нужен только на сайте',
three: 'Мод входит сам, по коду из вкладки «Аккаунт»',
},
},
register: {
title: 'Новый аккаунт',
subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.',
emailLabel: 'Почта',
nickLabel: 'Ник на сайте',
nickHint: 'Не обязательно совпадает с ником в Minecraft.',
passwordLabel: 'Пароль',
passwordHint: 'Минимум 8 символов.',
submit: 'Создать аккаунт',
haveAccount: 'Уже есть аккаунт?',
login: 'Войти',
emailTaken: 'Эта почта уже занята. Войди или используй другую.',
hints: {
one: 'Аккаунт - 4 облачных слота под конфиги',
two: 'Код слота - скинуть другу или открыть на другом компе',
three: 'Пароль в мод вводить не придётся, он входит по коду',
four: 'Без аккаунта мод тоже работает',
},
},
validation: {
emailRequired: 'Введи почту.',

View file

@ -0,0 +1,51 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { routes } from '@app/routes'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
test('the tab bar switches between login and register', async () => {
const user = userEvent.setup()
mockFetch(anon)
renderApp(routes, '/login')
expect(await screen.findByRole('heading', { level: 1, name: 'С возвращением' })).toBeInTheDocument()
const tabs = screen.getByRole('navigation', { name: 'Вход или регистрация' })
expect(within(tabs).getByRole('link', { name: 'Вход' })).toHaveAttribute('aria-current', 'page')
await user.click(within(tabs).getByRole('link', { name: 'Регистрация' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Новый аккаунт' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Регистрация' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('list', { name: 'Подсказки' })).toHaveTextContent('4 облачных слота')
})
test('a redirect from /link says what comes next and survives the tab switch', async () => {
const user = userEvent.setup()
mockFetch(anon)
renderApp(routes, '/link')
expect(await screen.findByText('Дальше: подтвердишь код из игры.')).toBeInTheDocument()
await user.click(screen.getByRole('link', { name: 'Регистрация' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Новый аккаунт' })).toBeInTheDocument()
expect(screen.getByText('Дальше: подтвердишь код из игры.')).toBeInTheDocument()
})
test('registering after a redirect lands back where the visitor was going', async () => {
const user = userEvent.setup()
mockFetch({
...anon,
'POST /auth/register': () => json({}, 201),
'POST /auth/login': () => json({ access_token: 't' }),
'GET /me': () => json(me),
})
const { router } = renderApp(routes, '/link')
await user.click(await screen.findByRole('link', { name: 'Регистрация' }))
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
await user.type(screen.getByLabelText('Ник на сайте'), 'Rider')
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
await screen.findByLabelText('Код из игры')
expect(router.state.location.pathname).toBe('/link')
})