diff --git a/frontend/src/features/auth/forms/LoginForm.tsx b/frontend/src/features/auth/forms/LoginForm.tsx index a6e0f57..b3e93ac 100644 --- a/frontend/src/features/auth/forms/LoginForm.tsx +++ b/frontend/src/features/auth/forms/LoginForm.tsx @@ -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() { -

- {t('login.noAccount')}{' '} - - {t('login.createAccount')} - -

) } diff --git a/frontend/src/features/auth/forms/RegisterForm.tsx b/frontend/src/features/auth/forms/RegisterForm.tsx index b1f4aa0..bd4bde3 100644 --- a/frontend/src/features/auth/forms/RegisterForm.tsx +++ b/frontend/src/features/auth/forms/RegisterForm.tsx @@ -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() { -

- {t('register.haveAccount')}{' '} - - {t('register.login')} - -

) } diff --git a/frontend/src/features/auth/i18n/en.ts b/frontend/src/features/auth/i18n/en.ts index c59f265..d210bf7 100644 --- a/frontend/src/features/auth/i18n/en.ts +++ b/frontend/src/features/auth/i18n/en.ts @@ -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.', diff --git a/frontend/src/features/auth/i18n/ru.ts b/frontend/src/features/auth/i18n/ru.ts index 672b50f..5f7e5fe 100644 --- a/frontend/src/features/auth/i18n/ru.ts +++ b/frontend/src/features/auth/i18n/ru.ts @@ -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: 'Введи почту.', diff --git a/frontend/src/features/auth/tests/pages.test.tsx b/frontend/src/features/auth/tests/pages.test.tsx new file mode 100644 index 0000000..13c18a5 --- /dev/null +++ b/frontend/src/features/auth/tests/pages.test.tsx @@ -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') +}) diff --git a/frontend/src/pages/public/auth/AuthScreen.tsx b/frontend/src/pages/public/auth/AuthScreen.tsx new file mode 100644 index 0000000..c77a439 --- /dev/null +++ b/frontend/src/pages/public/auth/AuthScreen.tsx @@ -0,0 +1,100 @@ +import { MotionConfig, motion } from 'motion/react' +import { type ReactNode, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, useLocation } from 'react-router' +import { Backdrop } from '@features/clickgui/parts/Backdrop' +import '@features/clickgui/parts/clickgui.css' +import { themeToCssVars } from '@features/clickgui/themeVars' +import { useActiveTheme } from '@features/themes/useActiveTheme' +import './auth.css' + +type Mode = 'login' | 'register' + +/** Where RequireAuth (or a "log in to copy" link) was sending the visitor, if anywhere we can name. */ +function nextStep(from: string | undefined): 'link' | 'configs' | 'account' | 'showcase' | undefined { + if (!from) return undefined + if (from.startsWith('/link')) return 'link' + if (from.startsWith('/configs')) return 'configs' + if (from.startsWith('/account')) return 'account' + if (from.startsWith('/showcase')) return 'showcase' +} + +function useClock(): string { + const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + const [now, setNow] = useState(format) + useEffect(() => { + const id = window.setInterval(() => setNow(format()), 15_000) + return () => window.clearInterval(id) + }, []) + return now +} + +/** + * The login and register pages as the mod's menu opened on an account tab: the ClickGui tab bar + * switches between the two pages, one glass panel holds the form, and the hint row along the + * bottom says what an account actually does (in the mod's own "key - action" voice). + */ +export function AuthScreen({ mode, title, hints, children }: { mode: Mode; title: string; hints: readonly string[]; children: ReactNode }) { + const { t } = useTranslation('auth') + const { theme } = useActiveTheme() + const location = useLocation() + const clock = useClock() + // Carry the redirect target across the tab switch, so registering from /link still ends on /link. + const state = location.state as { from?: string } | null + const step = nextStep(state?.from) + + return ( + +
+ + +
+ + +
+ +
+
+

+ {title} +

+
+
+ {step ?

{t(`next.${step}`)}

: null} + {children} +
+
+ + +
+
+ ) +} diff --git a/frontend/src/pages/public/auth/AuthSplit.tsx b/frontend/src/pages/public/auth/AuthSplit.tsx deleted file mode 100644 index ee8ede5..0000000 --- a/frontend/src/pages/public/auth/AuthSplit.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { ReactNode } from 'react' -import { useTranslation } from 'react-i18next' -import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow' -import { ParticleField } from '@features/landing/effects/ParticleField' -import { VoxelScene } from '@features/landing/effects/VoxelScene' -import { useActiveTheme } from '@features/themes/useActiveTheme' -import { parseArgb } from '@features/themes/types' - -/** Two-pane auth layout: a ClickGui-styled glass form panel, and a decorative scene (hidden < 900px). */ -export function AuthSplit({ title, children }: { title: string; children: ReactNode }) { - const { t } = useTranslation('auth') - const { theme } = useActiveTheme() - const { r, g, b } = parseArgb(theme.theme.accent) - const particleColor = `rgb(${r}, ${g}, ${b})` - - return ( -
-
-
-
-
{children}
-
- -
- - - {/* Bottom scrim keeps the caption readable over the bright pixel-art ground. */} - -
- ) -} diff --git a/frontend/src/pages/public/auth/LoginPage.tsx b/frontend/src/pages/public/auth/LoginPage.tsx index 28604a6..69998ed 100644 --- a/frontend/src/pages/public/auth/LoginPage.tsx +++ b/frontend/src/pages/public/auth/LoginPage.tsx @@ -1,12 +1,14 @@ import { useTranslation } from 'react-i18next' import { LoginForm } from '@features/auth/forms/LoginForm' -import { AuthSplit } from './AuthSplit' +import { AuthScreen } from './AuthScreen' + +const HINTS = ['one', 'two', 'three'] as const export default function LoginPage() { const { t } = useTranslation('auth') return ( - + t(`login.hints.${k}`))}> - + ) } diff --git a/frontend/src/pages/public/auth/RegisterPage.tsx b/frontend/src/pages/public/auth/RegisterPage.tsx index 5c8f687..3b05fbd 100644 --- a/frontend/src/pages/public/auth/RegisterPage.tsx +++ b/frontend/src/pages/public/auth/RegisterPage.tsx @@ -1,13 +1,14 @@ import { useTranslation } from 'react-i18next' import { RegisterForm } from '@features/auth/forms/RegisterForm' -import { AuthSplit } from './AuthSplit' +import { AuthScreen } from './AuthScreen' + +const HINTS = ['one', 'two', 'three', 'four'] as const export default function RegisterPage() { const { t } = useTranslation('auth') return ( - -

{t('register.subtitle')}

+ t(`register.hints.${k}`))}> -
+ ) } diff --git a/frontend/src/pages/public/auth/auth.css b/frontend/src/pages/public/auth/auth.css new file mode 100644 index 0000000..327cf39 --- /dev/null +++ b/frontend/src/pages/public/auth/auth.css @@ -0,0 +1,153 @@ +/* Login / register: the page is the mod's own menu, opened on an account tab. The world + backdrop, the ClickGuiTabBar pill, one liquid-glass panel and the hint row are the same + pieces the in-game ClickGui draws, painted with the active theme's --gui-* colors + (see features/clickgui/parts/clickgui.css for the registered properties). */ + +.lv-auth-screen { + position: relative; + isolation: isolate; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 1.5rem; + min-height: max(640px, calc(100svh - var(--topbar-h) - 3.5rem)); + margin-block: 1rem 2.5rem; + padding: 1rem 1rem 1.25rem; + overflow: hidden; + border-radius: 14px; + color: var(--gui-text); + box-shadow: 0 40px 120px -40px var(--gui-accent-glow), 0 0 0 1px rgb(255 255 255 / 0.07); +} +@media (min-width: 640px) { + .lv-auth-screen { padding: 1.25rem 1.75rem 1.5rem; } +} +.lv-auth-world { position: absolute; inset: 0; z-index: -1; background: #020309; } +/* Keeps the lake from out-shouting the panel; the accent pools under it like the in-game glow. */ +.lv-auth-world::after { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(60% 55% at 50% 55%, color-mix(in srgb, var(--gui-accent) 18%, transparent), transparent 70%), + linear-gradient(180deg, rgb(2 3 9 / 0.2), rgb(2 3 9 / 0.55)); +} + +/* ClickGuiTabBar at page scale. */ +.lv-auth-bar { display: flex; justify-content: center; align-items: center; gap: 0.6rem; } +.lv-auth-clock { + padding: 0.35rem 0.9rem; + border-radius: 999px; + background: rgb(10 10 12 / 0.88); + border: 1px solid rgb(255 255 255 / 0.12); + font-size: 0.85rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.lv-auth-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 0.25rem; + border-radius: 999px; + background: linear-gradient(90deg, var(--gui-tab-left), var(--gui-tab-right)); + border: 1px solid color-mix(in srgb, var(--gui-text) 14%, transparent); + box-shadow: 0 6px 22px rgb(0 0 0 / 0.35); +} +.lv-auth-tab { + position: relative; + min-width: 7.5rem; + padding: 0.45rem 1.1rem; + border-radius: 999px; + text-align: center; + font-size: 0.9rem; + font-weight: 600; + color: var(--gui-text-muted); + transition: color 250ms; +} +.lv-auth-tab:hover { color: color-mix(in srgb, var(--gui-text-muted), var(--gui-text) 55%); } +.lv-auth-tab[aria-current="page"] { color: var(--gui-text); } +.lv-auth-tab-text { position: relative; } +.lv-auth-thumb { + position: absolute; + inset: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--gui-tab-active-left), var(--gui-tab-active-right)); + border: 1px solid color-mix(in srgb, var(--gui-accent) 80%, white); + box-shadow: 0 0 18px color-mix(in srgb, var(--gui-accent-soft) 45%, transparent); +} + +/* The one panel: same liquid glass as the mod's category panels, grown to hold a form. */ +.lv-auth-panel { + align-self: center; + justify-self: center; + width: min(100%, 27rem); + border-radius: 14px; + border: 1px solid var(--gui-panel-stroke); + background: + linear-gradient(180deg, rgb(255 255 255 / 0.05), transparent 12%), + linear-gradient(180deg, + color-mix(in srgb, var(--gui-panel-tint) 94%, black) 0%, + color-mix(in srgb, var(--gui-panel-tint) 86%, transparent) 45%, + color-mix(in srgb, var(--gui-panel-tint) 72%, transparent) 100%); + box-shadow: 0 24px 60px rgb(0 0 0 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.1); + backdrop-filter: blur(14px) saturate(1.3); +} +.lv-auth-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.1rem 1.5rem 1rem; + border-bottom: 1px solid var(--gui-split); +} +.lv-auth-title { font-size: 1.35rem; line-height: 1.2; letter-spacing: -0.01em; color: var(--gui-text); } +.lv-auth-body { display: flex; flex-direction: column; gap: 1.1rem; padding: 1.25rem 1.5rem 1.5rem; } +.lv-auth-next { display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.9rem; color: var(--gui-text-muted); } +.lv-auth-next::before { + content: ""; + flex: none; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--gui-accent); + box-shadow: 0 0 10px var(--gui-accent); + transform: translateY(-0.05rem); +} + +/* Shared TextField / link colors re-pointed at the theme inside the panel. Unlayered, so these + win over the Tailwind utilities on the shared components. */ +.lv-auth-panel label, .lv-auth-panel form p { color: var(--gui-text-muted); } +.lv-auth-panel input { + background: color-mix(in srgb, var(--gui-window-from) 70%, black); + border-color: color-mix(in srgb, var(--gui-text) 14%, transparent); + color: var(--gui-text); + transition: border-color 150ms, box-shadow 150ms; +} +.lv-auth-panel input:focus { + border-color: var(--gui-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 22%, transparent); + outline: none; +} +.lv-auth-panel input[aria-invalid="true"] { border-color: var(--color-ember); } +.lv-auth-panel form p[id$="-error"] { color: var(--color-ember); } +.lv-auth-panel button[type="submit"] { padding-block: 0.7rem; font-size: 0.95rem; } + +/* The in-game hint row: plain lines along the bottom edge of the screen. */ +.lv-auth-hints { + display: grid; + gap: 0.2rem 2rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.85rem; + line-height: 1.45; + color: rgb(255 255 255 / 0.78); + text-shadow: 0 1px 2px rgb(0 0 0 / 0.6); +} +@media (min-width: 768px) { + .lv-auth-hints { grid-template-columns: 1fr 1fr; } +} + +/* Phones: the tab switch needs the whole bar. */ +@media (max-width: 479px) { + .lv-auth-clock { display: none; } + .lv-auth-tab { min-width: 0; } +}