chore(history): squash 67 commit(s) from 2026-09-25
- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints - feat(frontend): app shell, routing and landing page with the chat-command hero - feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion - docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans - feat(accounts): internal gRPC AuthenticateDevice guarded by internal key - feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch - feat(accounts): GET /me profile endpoint - feat(gateway): scaffold crate with config validation and health check - feat(gateway): reverse proxy to accounts and configs services - feat(gateway): resolve identity once from access JWT or device token via gRPC - feat(gateway): per-route and global rate limits with Retry-After - feat(gateway): CORS for the site origin; docs for gateway and internal contract - feat(configs): scaffold service with schema, config validation and health check - feat(configs): four config slots per account with list, get and save - feat(configs): permanent share codes with regenerate and public load-by-code - feat(accounts): GetPublicProfiles gRPC for showcase author info - style(accounts,common): apply rustfmt to existing sources - feat(configs): public showcase with publish, browse, detail and copy-to-slot - feat(backend): public profile endpoint and showcase author filter - fix(gateway): silence clippy collapsible-if and needless-ref warnings - docs(backend): configs-service implemented; Подсистема 1 backend complete - feat(mod): add Optimize module skeleton with OptimizeState holder - feat(mod): gate glass blur behind Optimize no_glass knob - feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob - feat(mod): trim procedural sky noise behind lite_sky knob - feat(mod): drop fade gradients and digit rolls behind lean_hud knob - docs(todo): mark Optimize module phase 9.2 complete - refactor(mod): drop dead Renderer2D compatibility shims - refactor(mod): prune unreachable Renderer2D overload towers - refactor(mod): remove unused Renderer2D overloads and imports - docs(todo): mark Renderer2D giant-splitting done (2179 to 1597) - refactor(mod): extract shader id constants from LoVisualRenderPipelines - docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines) - refactor(mod): move Renderer2D instance state into base class - refactor(mod): extract Renderer2DRounded drawing family - refactor(mod): extract Renderer2DPath connector and chamfer family - refactor(mod): extract Renderer2DShapes circle line and texture primitives - refactor(mod): extract Renderer2DGlass and Renderer2DItem families - refactor(mod): prune Renderer2D imports after facade split - docs(todo): record Renderer2D facade inheritance split (1597 to 475) - docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot - feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer) - refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants) - refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec - feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution - feat(mod): flat fallback for no-glass optimize mode and persist global HUD config - feat(mod): default HUD bg effects to Auto so the global surface style drives widgets - feat(mod): add global cycle-style hotkey with surface style notification - feat(mod): add surface style swatch strip under the global style picker - feat(gateway): reject ambiguous paths and answer .env probes with a honeypot - fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth - feat(frontend): ClickGui theme pipeline generated from the mod, live site theming - feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip - docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9 - feat(gateway): answer /coffee with a 418 teapot - feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer - feat(frontend): one-click download from GitHub releases, changelog page, release CI - feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links - fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping) - style(frontend): apply ClickGui glass effect to landing HUD playground widgets - fix(frontend): prevent color field row overflow in theme editor grid - fix(frontend): never attach stale bearer token to /auth/* requests - fix(configs): unpublish/publish can no longer bypass moderation - refactor(accounts): shrink auth/handlers.rs under the 250-line cap - fix(accounts): tolerate concurrent refresh without killing every session - fix(gateway): minor hardening from the backend review - feat(configs): IDDQD easter egg config
This commit is contained in:
parent
72bc4c7148
commit
7f4b532f99
257 changed files with 13085 additions and 6582 deletions
|
|
@ -68,7 +68,7 @@ export function createApiClient(
|
|||
headers.set('content-type', 'application/json')
|
||||
body = JSON.stringify(options.json)
|
||||
}
|
||||
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
if (accessToken && !path.startsWith('/auth/')) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
return fetchImpl(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import { ApiError } from './client'
|
||||
|
||||
const BY_STATUS: Record<number, string> = {
|
||||
400: 'Проверь введённые данные.',
|
||||
401: 'Нужно войти в аккаунт.',
|
||||
403: 'Недостаточно прав.',
|
||||
404: 'Не найдено.',
|
||||
409: 'Уже существует.',
|
||||
}
|
||||
const BY_STATUS = {
|
||||
400: 'errors.badRequest',
|
||||
401: 'errors.unauthorized',
|
||||
403: 'errors.forbidden',
|
||||
404: 'errors.notFound',
|
||||
409: 'errors.conflict',
|
||||
} as const
|
||||
|
||||
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
export function describeError(t: TFunction<'common'>, err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 429) {
|
||||
return err.retryAfter
|
||||
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
|
||||
: 'Слишком много попыток. Подожди немного.'
|
||||
? t('errors.rateLimited', { seconds: err.retryAfter })
|
||||
: t('errors.rateLimitedGeneric')
|
||||
}
|
||||
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
|
||||
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
|
||||
if (err.status >= 500) return t('errors.serverError')
|
||||
const key = BY_STATUS[err.status as keyof typeof BY_STATUS]
|
||||
return overrides[err.status] ?? (key ? t(key) : t('errors.badRequest'))
|
||||
}
|
||||
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
|
||||
return t('errors.network')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { ApiError, createApiClient } from '../client'
|
||||
import { describeError } from '../errors'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
const t = initI18n('ru').getFixedT('ru', 'common')
|
||||
|
||||
test('sends json with bearer token and cookies', async () => {
|
||||
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
|
||||
const api = createApiClient()
|
||||
|
|
@ -62,6 +65,15 @@ test('auth endpoints never trigger refresh', async () => {
|
|||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('never sends a stale bearer token on /auth/* requests', async () => {
|
||||
const fetch = mockFetch({ 'POST /auth/logout': () => new Response(null, { status: 204 }) })
|
||||
const api = createApiClient()
|
||||
api.setAccessToken('stale')
|
||||
await api.request('/auth/logout', { method: 'POST' })
|
||||
const [, init] = fetch.mock.calls[0]
|
||||
expect(new Headers(init?.headers).get('authorization')).toBeNull()
|
||||
})
|
||||
|
||||
test('429 carries retry-after; 204 resolves undefined', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
|
||||
|
|
@ -73,10 +85,10 @@ test('429 carries retry-after; 204 resolves undefined', async () => {
|
|||
})
|
||||
|
||||
test('describeError covers 429, network failure and status defaults', () => {
|
||||
expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
|
||||
expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
|
||||
expect(describeError(new TypeError('Failed to fetch'))).toBe(
|
||||
expect(describeError(t, new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
|
||||
expect(describeError(t, new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
|
||||
expect(describeError(t, new TypeError('Failed to fetch'))).toBe(
|
||||
'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
|
||||
)
|
||||
expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
expect(describeError(t, new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
})
|
||||
|
|
|
|||
24
frontend/src/shared/i18n/LanguageSwitch.tsx
Normal file
24
frontend/src/shared/i18n/LanguageSwitch.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LANGS = ['ru', 'en'] as const
|
||||
|
||||
export function LanguageSwitch() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const current = i18n.language
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm text-frost" role="group" aria-label="Language">
|
||||
{LANGS.map((lng) => (
|
||||
<button
|
||||
key={lng}
|
||||
type="button"
|
||||
aria-pressed={current === lng}
|
||||
onClick={() => i18n.changeLanguage(lng)}
|
||||
className={`rounded-md px-2 py-1 transition-colors ${current === lng ? 'text-snow' : 'hover:text-snow'}`}
|
||||
>
|
||||
{t(`languageSwitch.${lng}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
frontend/src/shared/i18n/common.en.ts
Normal file
52
frontend/src/shared/i18n/common.en.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { commonRu, Translation } from './common.ru'
|
||||
|
||||
export const commonEn = {
|
||||
skipToContent: 'Skip to content',
|
||||
nav: {
|
||||
mainNav: 'Main navigation',
|
||||
showcase: 'Showcase',
|
||||
myConfigs: 'My configs',
|
||||
linkGame: 'Link game',
|
||||
menu: 'Menu',
|
||||
login: 'Log in',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
body: 'This link may be outdated.',
|
||||
home: 'Go home',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
},
|
||||
languageSwitch: {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
footer: {
|
||||
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
|
||||
sectionProduct: 'Product',
|
||||
sectionProject: 'Project',
|
||||
github: 'GitHub',
|
||||
download: 'Download',
|
||||
legalTitle: 'Legal',
|
||||
disclaimer: 'Not affiliated with Mojang or Microsoft.',
|
||||
},
|
||||
time: {
|
||||
days_one: '{{count}} day',
|
||||
days_few: '{{count}} days',
|
||||
days_many: '{{count}} days',
|
||||
days_other: '{{count}} days',
|
||||
},
|
||||
errors: {
|
||||
badRequest: 'Check what you entered.',
|
||||
unauthorized: 'You need to log in.',
|
||||
forbidden: 'Not enough permissions.',
|
||||
notFound: 'Not found.',
|
||||
conflict: 'Already exists.',
|
||||
rateLimited: 'Too many attempts. Wait {{seconds}}s.',
|
||||
rateLimitedGeneric: 'Too many attempts. Wait a bit.',
|
||||
serverError: "The server didn't respond. Try again in a minute.",
|
||||
network: 'No connection to the server. Check your internet and try again.',
|
||||
},
|
||||
} satisfies Translation<typeof commonRu>
|
||||
54
frontend/src/shared/i18n/common.ru.ts
Normal file
54
frontend/src/shared/i18n/common.ru.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export const commonRu = {
|
||||
skipToContent: 'Перейти к содержимому',
|
||||
nav: {
|
||||
mainNav: 'Основная навигация',
|
||||
showcase: 'Витрина',
|
||||
myConfigs: 'Мои конфиги',
|
||||
linkGame: 'Привязать игру',
|
||||
menu: 'Меню',
|
||||
login: 'Войти',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Такой страницы нет',
|
||||
body: 'Возможно, ссылка устарела.',
|
||||
home: 'На главную',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Скопировать',
|
||||
copied: 'Скопировано',
|
||||
},
|
||||
languageSwitch: {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
footer: {
|
||||
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
|
||||
sectionProduct: 'Продукт',
|
||||
sectionProject: 'Проект',
|
||||
github: 'GitHub',
|
||||
download: 'Скачать',
|
||||
legalTitle: 'Правовое',
|
||||
disclaimer: 'Не связано с Mojang или Microsoft.',
|
||||
},
|
||||
time: {
|
||||
days_one: '{{count}} день',
|
||||
days_few: '{{count}} дня',
|
||||
days_many: '{{count}} дней',
|
||||
days_other: '{{count}} дня',
|
||||
},
|
||||
errors: {
|
||||
badRequest: 'Проверь введённые данные.',
|
||||
unauthorized: 'Нужно войти в аккаунт.',
|
||||
forbidden: 'Недостаточно прав.',
|
||||
notFound: 'Не найдено.',
|
||||
conflict: 'Уже существует.',
|
||||
rateLimited: 'Слишком много попыток. Подожди {{seconds}} с.',
|
||||
rateLimitedGeneric: 'Слишком много попыток. Подожди немного.',
|
||||
serverError: 'Сервер не ответил. Попробуй через минуту.',
|
||||
network: 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Translation<T> = {
|
||||
[K in keyof T]: T[K] extends string ? string : Translation<T[K]>
|
||||
}
|
||||
33
frontend/src/shared/i18n/tests/i18n.test.tsx
Normal file
33
frontend/src/shared/i18n/tests/i18n.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next'
|
||||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { LanguageSwitch } from '../LanguageSwitch'
|
||||
|
||||
function Probe() {
|
||||
const { t } = useTranslation('auth')
|
||||
return <p>{t('login.submit')}</p>
|
||||
}
|
||||
|
||||
test('switching language re-renders strings and sets <html lang>', async () => {
|
||||
const user = userEvent.setup()
|
||||
const i18n = initI18n('ru')
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Probe />
|
||||
<LanguageSwitch />
|
||||
</I18nextProvider>,
|
||||
)
|
||||
expect(screen.getByText('Войти')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'EN' }))
|
||||
expect(screen.getByText('Log in')).toBeInTheDocument()
|
||||
expect(document.documentElement.lang).toBe('en')
|
||||
})
|
||||
|
||||
test('russian plurals', () => {
|
||||
const i18n = initI18n('ru')
|
||||
expect(i18n.t('common:time.days', { count: 1 })).toBe('1 день')
|
||||
expect(i18n.t('common:time.days', { count: 3 })).toBe('3 дня')
|
||||
expect(i18n.t('common:time.days', { count: 5 })).toBe('5 дней')
|
||||
})
|
||||
23
frontend/src/shared/layout/AppShell.tsx
Normal file
23
frontend/src/shared/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Outlet } from 'react-router'
|
||||
import { Footer } from './Footer'
|
||||
import { TopBar } from './TopBar'
|
||||
|
||||
export function AppShell() {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:z-10 focus:rounded-md focus:bg-slate focus:px-3 focus:py-2"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
<TopBar />
|
||||
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 sm:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
49
frontend/src/shared/layout/Footer.tsx
Normal file
49
frontend/src/shared/layout/Footer.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { DOWNLOAD_URL, GITHUB_REPO } from '../../features/download/api'
|
||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||
|
||||
export function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
const product = [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/themes', label: 'Themes' },
|
||||
]
|
||||
const project = [
|
||||
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
|
||||
{ href: DOWNLOAD_URL, label: t('footer.download') },
|
||||
]
|
||||
|
||||
return (
|
||||
<footer className="mt-8 border-t border-shoal bg-slate">
|
||||
<div className="mx-auto grid w-full max-w-[1120px] gap-10 px-4 py-12 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Link to="/" className="font-display text-xl font-bold text-snow">LoVisual</Link>
|
||||
<p className="max-w-[38ch] text-sm text-frost">{t('footer.about')}</p>
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
|
||||
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProduct')}</span>
|
||||
{product.map((i) => (
|
||||
<Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProject')}</span>
|
||||
{project.map((i) => (
|
||||
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
|
||||
{i.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-shoal">
|
||||
<p className="mx-auto w-full max-w-[1120px] px-4 py-5 text-xs text-frost sm:px-6">{t('footer.disclaimer')}</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
94
frontend/src/shared/layout/TopBar.tsx
Normal file
94
frontend/src/shared/layout/TopBar.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, NavLink } from 'react-router'
|
||||
import { useSession } from '../../features/auth/session'
|
||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||
import type { Me } from '../types'
|
||||
import { ButtonLink } from '../ui/Button'
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation(['common', 'download'])
|
||||
const [open, setOpen] = useState(false)
|
||||
const nav = [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/link', label: t('nav.linkGame') },
|
||||
]
|
||||
|
||||
return (
|
||||
<header className="border-b border-shoal bg-slate">
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-wrap items-center gap-x-6 px-4 sm:px-6">
|
||||
<Link to="/" className="py-4 font-display text-xl font-bold text-snow">
|
||||
LoVisual
|
||||
</Link>
|
||||
<nav
|
||||
id="site-nav"
|
||||
aria-label={t('nav.mainNav')}
|
||||
className={`${open ? 'flex' : 'hidden'} order-last w-full flex-col gap-1 pb-3 sm:order-none sm:flex sm:w-auto sm:flex-row sm:gap-6 sm:pb-0`}
|
||||
>
|
||||
{nav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`py-2 text-sm transition-colors ${isActive ? 'text-snow' : 'text-frost hover:text-snow'}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<ButtonLink to="/download" variant="primary">
|
||||
{t('download:nav')}
|
||||
</ButtonLink>
|
||||
<LanguageSwitch />
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="site-nav"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-md px-2 py-2 text-sm text-frost hover:text-snow sm:hidden"
|
||||
>
|
||||
{t('nav.menu')}
|
||||
</button>
|
||||
<AccountButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountButton() {
|
||||
const { t } = useTranslation('common')
|
||||
const { status, me } = useSession()
|
||||
if (status === 'loading') return null
|
||||
if (!me) {
|
||||
return (
|
||||
<ButtonLink to="/login" variant="quiet">
|
||||
{t('nav.login')}
|
||||
</ButtonLink>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link to="/account" className="flex min-w-0 items-center gap-2 py-2 text-sm text-snow hover:text-ice">
|
||||
<Avatar me={me} />
|
||||
<span className="max-w-[8rem] truncate sm:max-w-[12rem]">{me.display_nick}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({ me }: { me: Me }) {
|
||||
if (me.avatar_url) {
|
||||
return <img src={me.avatar_url} alt="" className="size-7 shrink-0 rounded-full object-cover" />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-shoal font-display text-xs font-bold text-snow"
|
||||
>
|
||||
{me.display_nick.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
47
frontend/src/shared/layout/tests/layout.test.tsx
Normal file
47
frontend/src/shared/layout/tests/layout.test.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { screen } 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'
|
||||
|
||||
test('anonymous visitor sees nav and a login link', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login')
|
||||
expect(screen.getAllByRole('link', { name: 'Витрина' }).length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, которые видно')
|
||||
})
|
||||
|
||||
test('signed-in visitor sees their nick instead of login', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({ access_token: 't' }),
|
||||
'GET /me': () => json({ id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '' }),
|
||||
})
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByRole('link', { name: /Rider/ })).toHaveAttribute('href', '/account')
|
||||
expect(screen.queryByRole('link', { name: 'Войти' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('unknown path shows not found', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/definitely-not-here')
|
||||
expect(await screen.findByRole('heading', { name: 'Такой страницы нет' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('menu disclosure toggles aria-expanded', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
const toggle = await screen.findByRole('button', { name: 'Меню' })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await userEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('footer carries the github link and the disclaimer', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
|
||||
const gh = screen.getByRole('link', { name: 'GitHub' })
|
||||
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
|
||||
})
|
||||
7
frontend/src/shared/motion/reducedMotion.ts
Normal file
7
frontend/src/shared/motion/reducedMotion.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* True when the visitor asked for reduced motion. Unknown support (no matchMedia, e.g. jsdom)
|
||||
* counts as reduced: the safe default, and it keeps tests deterministic.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window.matchMedia !== 'function' || window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import { Link, type LinkProps } from 'react-router'
|
||||
|
||||
type Variant = 'primary' | 'quiet' | 'danger'
|
||||
|
||||
const BASE =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors'
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
primary: 'bg-ice text-abyss hover:bg-ice/90',
|
||||
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
|
||||
|
|
@ -21,8 +25,13 @@ export function Button({
|
|||
type={type}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
className={`${BASE} disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** A router link that looks like a button — for navigation CTAs. */
|
||||
export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) {
|
||||
return <Link className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from './Button'
|
||||
|
||||
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
||||
const { t } = useTranslation('common')
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
|
|
@ -19,10 +21,10 @@ export function ShareCode({ code, label }: { code: string; label?: string }) {
|
|||
{label ? <span className="text-sm text-frost">{label}</span> : null}
|
||||
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
|
||||
<Button variant="quiet" onClick={copy}>
|
||||
Скопировать
|
||||
{t('shareCode.copy')}
|
||||
</Button>
|
||||
<span aria-live="polite" className="text-sm text-frost">
|
||||
{copied ? 'Скопировано' : ''}
|
||||
{copied ? t('shareCode.copied') : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue