chore(history): squash 16 commit(s) from 2026-09-26
- chore(backend): sync Cargo.lock with gateway dev-dependency - feat(frontend): ClickGui replica matching the in-game GUI — category panels, Modules/Settings switch, theme cards - feat(frontend): floating glass top bar in the mod's UI language - fix(frontend): soft night backdrop for the ClickGui, editor that fits one screen - chore(frontend): replace em dashes in UI copy with lighter separators - refactor(frontend): split landing sections and theme editor into subfolders (4-files-per-folder rule) - feat(frontend): login and registration in the mod's glass panel style - feat(frontend): link the game with a device code - feat(frontend): account page with avatar upload and linked games - feat(frontend): my configs with share codes and publishing - feat(frontend): public showcase listing and detail pages - chore(frontend): drop em dashes from visible copy - feat(frontend): atmospheric backdrop, glass download block and ClickGui-style FAQ on the landing - feat(frontend): Konami code, DevTools console greeting and ASCII 404 block - feat: public player profiles at /u/:id and author link in the showcase - fix(frontend): stop the theme editor overflowing horizontally at 375px
This commit is contained in:
parent
7f4b532f99
commit
ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions
20
frontend/src/shared/easter/KonamiEffect.tsx
Normal file
20
frontend/src/shared/easter/KonamiEffect.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKonami } from './useKonami'
|
||||
|
||||
export function KonamiEffect() {
|
||||
const { t } = useTranslation('common')
|
||||
const active = useKonami()
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('lv-konami', active)
|
||||
return () => document.documentElement.classList.remove('lv-konami')
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
return (
|
||||
<div role="status" className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-panel border border-ice/40 bg-slate/90 px-4 py-2 font-code text-sm text-ice">
|
||||
{t('konami')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
frontend/src/shared/easter/art.generated.ts
Normal file
3
frontend/src/shared/easter/art.generated.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Generated with pyfiglet (fonts: slant, big). Do not edit by hand.
|
||||
export const LOGO_ART = " __ _ ___ __\n / / ____| | / (_)______ ______ _/ /\n / / / __ \\ | / / / ___/ / / / __ `/ / \n / /___/ /_/ / |/ / (__ ) /_/ / /_/ / / \n/_____/\\____/|___/_/____/\\__,_/\\__,_/_/"
|
||||
export const NOT_FOUND_ART = " _ _ ___ _ _ \n| || | / _ \\| || | \n| || |_| | | | || |_ \n|__ _| | | |__ _|\n | | | |_| | | | \n |_| \\___/ |_|"
|
||||
14
frontend/src/shared/easter/consoleGreeting.ts
Normal file
14
frontend/src/shared/easter/consoleGreeting.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { LOGO_ART } from './art.generated'
|
||||
|
||||
export function printConsoleGreeting(lang: string): void {
|
||||
const ru = lang.startsWith('ru')
|
||||
console.log(`%c${LOGO_ART}`, 'color:#5cc8e7;font-family:monospace;font-weight:bold')
|
||||
console.log(
|
||||
`%c${ru ? 'Открыл DevTools? Значит, тебе тут рады.' : 'Opened DevTools? You are welcome here.'}`,
|
||||
'color:#eef5f3;font-size:13px',
|
||||
)
|
||||
console.log(
|
||||
`%c${ru ? 'Совет: попробуй ↑ ↑ ↓ ↓ ← → ← → B A.' : 'Hint: try ↑ ↑ ↓ ↓ ← → ← → B A.'}`,
|
||||
'color:#9fb3b0;font-size:11px',
|
||||
)
|
||||
}
|
||||
20
frontend/src/shared/easter/tests/konami.test.tsx
Normal file
20
frontend/src/shared/easter/tests/konami.test.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { useKonami } from '../useKonami'
|
||||
|
||||
const press = (key: string) => act(() => void window.dispatchEvent(new KeyboardEvent('keydown', { key })))
|
||||
|
||||
describe('useKonami', () => {
|
||||
it('activates after the full code', () => {
|
||||
const { result } = renderHook(() => useKonami())
|
||||
;['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b'].forEach(press)
|
||||
expect(result.current).toBe(false)
|
||||
press('a')
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
it('ignores a wrong sequence', () => {
|
||||
const { result } = renderHook(() => useKonami())
|
||||
;['ArrowUp', 'ArrowDown', 'b', 'a'].forEach(press)
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
})
|
||||
29
frontend/src/shared/easter/useKonami.ts
Normal file
29
frontend/src/shared/easter/useKonami.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
const CODE = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']
|
||||
|
||||
/** Returns true for a few seconds after the Konami code is typed anywhere on the page. */
|
||||
export function useKonami(durationMs = 7000): boolean {
|
||||
const [active, setActive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
|
||||
index = key === CODE[index] ? index + 1 : key === CODE[0] ? 1 : 0
|
||||
if (index < CODE.length) return
|
||||
index = 0
|
||||
setActive(true)
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => setActive(false), durationMs)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [durationMs])
|
||||
|
||||
return active
|
||||
}
|
||||
|
|
@ -2,19 +2,28 @@ import { useTranslation } from 'react-i18next'
|
|||
|
||||
const LANGS = ['ru', 'en'] as const
|
||||
|
||||
export function LanguageSwitch() {
|
||||
/** RU/EN as a two-segment pill (the in-game switch shape); the accent thumb slides to the active one. */
|
||||
export function LanguageSwitch({ className = '' }: { className?: string }) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const current = i18n.language
|
||||
const current = i18n.language === 'en' ? 'en' : 'ru'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm text-frost" role="group" aria-label="Language">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('languageSwitch.label')}
|
||||
className={`relative grid h-8 shrink-0 grid-cols-2 rounded-full border border-white/10 bg-black/30 p-0.5 text-xs font-semibold ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-y-0.5 left-0.5 w-[calc(50%-2px)] rounded-full bg-white/12 shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12)] transition-transform duration-300 ease-out ${current === 'en' ? 'translate-x-full' : ''}`}
|
||||
/>
|
||||
{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'}`}
|
||||
className={`relative min-w-9 rounded-full px-2 transition-colors ${current === lng ? 'text-snow' : 'text-frost hover:text-snow'}`}
|
||||
>
|
||||
{t(`languageSwitch.${lng}`)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
import type { commonRu, Translation } from './common.ru'
|
||||
|
||||
export const commonEn = {
|
||||
konami: '+30 lives. Rainbow enabled.',
|
||||
skipToContent: 'Skip to content',
|
||||
nav: {
|
||||
mainNav: 'Main navigation',
|
||||
home: 'LoVisual · home',
|
||||
showcase: 'Showcase',
|
||||
themes: 'Themes',
|
||||
myConfigs: 'My configs',
|
||||
linkGame: 'Link game',
|
||||
menu: 'Menu',
|
||||
login: 'Log in',
|
||||
account: 'Account',
|
||||
sessionLoading: 'Checking sign-in',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
body: 'This link may be outdated.',
|
||||
home: 'Go home',
|
||||
console: '> chunk not found. Try again.',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
},
|
||||
languageSwitch: {
|
||||
label: 'Language',
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
export const commonRu = {
|
||||
skipToContent: 'Перейти к содержимому',
|
||||
konami: '+30 жизней. Радуга включена.',
|
||||
nav: {
|
||||
mainNav: 'Основная навигация',
|
||||
home: 'LoVisual · на главную',
|
||||
showcase: 'Витрина',
|
||||
themes: 'Темы',
|
||||
myConfigs: 'Мои конфиги',
|
||||
linkGame: 'Привязать игру',
|
||||
menu: 'Меню',
|
||||
login: 'Войти',
|
||||
account: 'Аккаунт',
|
||||
sessionLoading: 'Проверяем вход',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Такой страницы нет',
|
||||
body: 'Возможно, ссылка устарела.',
|
||||
home: 'На главную',
|
||||
console: '> chunk not found. Попробуй ещё раз.',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Скопировать',
|
||||
copied: 'Скопировано',
|
||||
},
|
||||
languageSwitch: {
|
||||
label: 'Язык',
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Outlet } from 'react-router'
|
||||
import { KonamiEffect } from '../easter/KonamiEffect'
|
||||
import { Footer } from './Footer'
|
||||
import { TopBar } from './TopBar'
|
||||
import { TopBar } from './topbar/TopBar'
|
||||
|
||||
export function AppShell() {
|
||||
const { t } = useTranslation('common')
|
||||
|
|
@ -9,12 +10,13 @@ export function AppShell() {
|
|||
<>
|
||||
<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"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:z-50 focus:rounded-md focus:bg-slate focus:px-3 focus:py-2"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
<KonamiEffect />
|
||||
<TopBar />
|
||||
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 sm:px-6">
|
||||
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 pt-(--topbar-h) sm:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export function Footer() {
|
|||
const product = [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/themes', label: 'Themes' },
|
||||
{ to: '/themes', label: t('nav.themes') },
|
||||
]
|
||||
const project = [
|
||||
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
|
||||
|
|
@ -21,7 +21,7 @@ export function Footer() {
|
|||
<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 />
|
||||
<LanguageSwitch className="self-start" />
|
||||
</div>
|
||||
|
||||
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { afterEach, expect, test } from 'vitest'
|
||||
import { routes } from '../../../app/routes'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
afterEach(async () => {
|
||||
await getI18n().changeLanguage('ru')
|
||||
})
|
||||
|
||||
test('anonymous visitor sees nav and a login link', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
|
|
@ -38,6 +43,59 @@ test('menu disclosure toggles aria-expanded', async () => {
|
|||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('the nav pill marks the active route with the sliding indicator', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/themes')
|
||||
const nav = (await screen.findAllByRole('navigation', { name: 'Основная навигация' }))[0]
|
||||
const active = within(nav).getByRole('link', { name: 'Темы' })
|
||||
expect(active).toHaveAttribute('aria-current', 'page')
|
||||
expect(within(active).getByTestId('nav-indicator')).toBeInTheDocument()
|
||||
expect(within(nav).getAllByTestId('nav-indicator')).toHaveLength(1)
|
||||
expect(within(nav).getByRole('link', { name: 'Витрина' })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
test('the mobile menu opens a sheet, traps focus and closes with Esc', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
const toggle = await screen.findByRole('button', { name: 'Меню' })
|
||||
await user.click(toggle)
|
||||
const sheet = document.getElementById(toggle.getAttribute('aria-controls')!)!
|
||||
expect(sheet).toBeInTheDocument()
|
||||
expect(within(sheet).getByRole('link', { name: 'Витрина' })).toHaveFocus()
|
||||
expect(within(sheet).getByRole('group', { name: 'Язык' })).toBeInTheDocument()
|
||||
await user.keyboard('{Shift>}{Tab}{Tab}{/Shift}')
|
||||
expect(within(sheet).getByRole('link', { name: 'Войти' })).toHaveFocus()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(toggle).toHaveFocus()
|
||||
})
|
||||
|
||||
test('the account slot shows a skeleton while the session restores', async () => {
|
||||
let release = () => {}
|
||||
mockFetch({ 'POST /auth/refresh': () => new Promise<Response>((r) => (release = () => r(json({}, 401)))) })
|
||||
renderApp(routes, '/nowhere')
|
||||
expect(await screen.findByRole('status', { name: 'Проверяем вход' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Войти' })).not.toBeInTheDocument()
|
||||
release()
|
||||
expect(await screen.findByRole('link', { name: 'Войти' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status', { name: 'Проверяем вход' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('the header language toggle switches the UI to English and back', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/nowhere')
|
||||
await screen.findByRole('link', { name: 'Войти' })
|
||||
const group = screen.getAllByRole('group', { name: 'Язык' })[0]
|
||||
await user.click(within(group).getByRole('button', { name: 'EN' }))
|
||||
expect(await screen.findByRole('link', { name: 'Log in' }, { timeout: 3000 })).toBeInTheDocument()
|
||||
const en = screen.getAllByRole('group', { name: 'Language' })[0]
|
||||
expect(within(en).getByRole('button', { name: 'EN' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(within(en).getByRole('button', { name: 'RU' }))
|
||||
expect(await screen.findByRole('link', { name: 'Войти' }, { timeout: 3000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('footer carries the github link and the disclaimer', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
|
|
|
|||
115
frontend/src/shared/layout/topbar/MobileMenu.tsx
Normal file
115
frontend/src/shared/layout/topbar/MobileMenu.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
|
||||
import { type RefObject, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavLink } from 'react-router'
|
||||
import { LanguageSwitch } from '../../i18n/LanguageSwitch'
|
||||
import type { NavItem } from './NavPill'
|
||||
import { AccountSlot } from './parts'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
items: NavItem[]
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** The menu button: focus returns there on close, and it stays inside the Tab loop. */
|
||||
toggleRef: RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/** The phone menu: a glass sheet under the bar with the nav, the language toggle and the account slot. */
|
||||
export function MobileMenu({ id, items, open, onClose, toggleRef }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
const sheetRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const toggle = toggleRef.current
|
||||
sheetRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus()
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
toggle?.focus()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab' || !sheetRef.current) return
|
||||
const loop = [toggle, ...sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el): el is HTMLElement => !!el)
|
||||
const first = loop[0]
|
||||
const last = loop[loop.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
} else if (!loop.includes(document.activeElement as HTMLElement)) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose, toggleRef])
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
key="scrim"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 -z-10 bg-abyss/70 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
key="sheet"
|
||||
id={id}
|
||||
ref={sheetRef}
|
||||
initial={{ opacity: 0, y: -10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="mt-2 origin-top rounded-3xl border border-white/10 bg-[rgb(12_20_22/0.95)] p-2 shadow-[0_24px_60px_-20px_rgb(0_0_0/0.8),inset_0_1px_0_rgb(255_255_255/0.06)] backdrop-blur-xl"
|
||||
>
|
||||
<nav aria-label={t('nav.mainNav')}>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center justify-between rounded-2xl px-4 py-3 text-base font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-ice/15 text-snow shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-ice)_45%,transparent)]'
|
||||
: 'text-frost hover:bg-white/5 hover:text-snow'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{item.label}
|
||||
{isActive && <span aria-hidden="true" className="size-1.5 rounded-full bg-ice shadow-[0_0_8px_var(--color-ice)]" />}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/10 px-2 pt-3 pb-1">
|
||||
<LanguageSwitch />
|
||||
<AccountSlot onNavigate={onClose} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
45
frontend/src/shared/layout/topbar/NavPill.tsx
Normal file
45
frontend/src/shared/layout/topbar/NavPill.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { MotionConfig, motion } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
export type NavItem = { to: string; label: string }
|
||||
|
||||
/** The in-game [Modules | Settings] switch as site navigation: an accent thumb slides to the active route. */
|
||||
export function NavPill({ items, className = '' }: { items: NavItem[]; className?: string }) {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<nav aria-label={t('nav.mainNav')} className={className}>
|
||||
<ul className="flex h-10 items-center rounded-full border border-white/10 bg-black/35 p-1 shadow-[inset_0_1px_2px_rgb(0_0_0/0.35)]">
|
||||
{items.map((item) => (
|
||||
<li key={item.to} className="h-full">
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`relative flex h-full items-center rounded-full px-4 text-sm font-medium whitespace-nowrap transition-colors duration-200 ${
|
||||
isActive ? 'text-abyss' : 'text-frost hover:text-snow'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="topbar-nav-thumb"
|
||||
data-testid="nav-indicator"
|
||||
aria-hidden="true"
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 36 }}
|
||||
className="absolute inset-0 rounded-full border border-white/25 bg-[linear-gradient(90deg,var(--color-ice),color-mix(in_srgb,var(--color-ice)_72%,white))] shadow-[0_0_18px_-2px_var(--page-accent-glow)]"
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
100
frontend/src/shared/layout/topbar/TopBar.tsx
Normal file
100
frontend/src/shared/layout/topbar/TopBar.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { LanguageSwitch } from '../../i18n/LanguageSwitch'
|
||||
import { MobileMenu } from './MobileMenu'
|
||||
import { NavPill } from './NavPill'
|
||||
import { AccountSlot, DownloadIcon, MenuIcon, Watermark } from './parts'
|
||||
|
||||
const MENU_ID = 'site-menu'
|
||||
|
||||
function useNavItems() {
|
||||
const { t } = useTranslation('common')
|
||||
return [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/themes', label: t('nav.themes') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/link', label: t('nav.linkGame') },
|
||||
]
|
||||
}
|
||||
|
||||
function useScrolled(threshold = 24): boolean {
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > threshold)
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [threshold])
|
||||
return scrolled
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating glass bar in the mod's own UI language: HUD-watermark logo, the [Modules | Settings]
|
||||
* pill as navigation, and a compact right cluster. It sits on the page's content column
|
||||
* (the bar overhangs by its own padding so the watermark lines up with the text below).
|
||||
*/
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation(['common', 'download'])
|
||||
const scrolled = useScrolled()
|
||||
const [open, setOpen] = useState(false)
|
||||
const toggleRef = useRef<HTMLButtonElement>(null)
|
||||
const { pathname } = useLocation()
|
||||
const items = useNavItems()
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
|
||||
// A route change (link in the sheet, back button) always closes the menu.
|
||||
const [lastPath, setLastPath] = useState(pathname)
|
||||
if (lastPath !== pathname) {
|
||||
setLastPath(pathname)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="fixed inset-x-0 top-0 z-40">
|
||||
<div
|
||||
className={`mx-auto w-full max-w-[1120px] px-2 transition-[padding] duration-300 sm:px-4 ${scrolled ? 'pt-2' : 'pt-3'}`}
|
||||
>
|
||||
<div
|
||||
data-scrolled={scrolled}
|
||||
className={`flex items-center gap-3 rounded-full border px-2 backdrop-blur-xl backdrop-saturate-150 transition-[height,background-color,border-color,box-shadow] duration-300 ${
|
||||
scrolled
|
||||
? 'h-13 border-white/12 bg-[rgb(10_17_19/0.9)] shadow-[0_14px_40px_-16px_rgb(0_0_0/0.9),inset_0_1px_0_rgb(255_255_255/0.06)]'
|
||||
: 'h-14 border-white/8 bg-[rgb(12_20_22/0.6)] shadow-[0_10px_40px_-20px_rgb(0_0_0/0.7),inset_0_1px_0_rgb(255_255_255/0.05)]'
|
||||
}`}
|
||||
>
|
||||
<Watermark />
|
||||
<NavPill items={items} className="mx-auto hidden lg:block" />
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<LanguageSwitch className="hidden lg:grid" />
|
||||
<Link
|
||||
to="/download"
|
||||
className="flex h-9 items-center gap-2 rounded-full bg-ice px-4 text-sm font-semibold text-abyss shadow-[0_0_20px_-4px_var(--page-accent-glow)] transition-[filter] hover:brightness-110 max-md:w-9 max-md:justify-center max-md:px-0"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<span className="max-md:sr-only">{t('download:nav')}</span>
|
||||
</Link>
|
||||
<AccountSlot className="hidden lg:flex" />
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={MENU_ID}
|
||||
aria-label={t('nav.menu')}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex size-9 items-center justify-center rounded-full border transition-colors lg:hidden ${
|
||||
open ? 'border-white/25 bg-white/10 text-snow' : 'border-white/10 bg-black/30 text-frost hover:text-snow'
|
||||
}`}
|
||||
>
|
||||
<MenuIcon open={open} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
<MobileMenu id={MENU_ID} items={items} open={open} onClose={close} toggleRef={toggleRef} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
86
frontend/src/shared/layout/topbar/parts.tsx
Normal file
86
frontend/src/shared/layout/topbar/parts.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { useSession } from '../../../features/auth/session'
|
||||
import type { Me } from '../../types'
|
||||
|
||||
/** The logo pill in the top bar; the in-game watermark's fps counter lives in the ClickGui replica. */
|
||||
export function Watermark() {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
aria-label={t('nav.home')}
|
||||
className="group flex h-9 shrink-0 items-center gap-1.5 rounded-xl border border-white/10 bg-black/45 px-3 shadow-[inset_0_1px_0_rgb(255_255_255/0.06)] transition-colors hover:border-white/20"
|
||||
>
|
||||
<span className="font-display text-[15px] font-bold tracking-tight text-ice drop-shadow-[0_0_10px_var(--page-accent-glow)]">
|
||||
LoVisual
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const SLOT = 'h-9 w-[8.5rem] shrink-0 rounded-full'
|
||||
|
||||
/** Login link or avatar + nick; a same-size skeleton while the session restores, so nothing jumps. */
|
||||
export function AccountSlot({ className = '', onNavigate }: { className?: string; onNavigate?: () => void }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { status, me } = useSession()
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<span role="status" aria-label={t('nav.sessionLoading')} className={`${SLOT} flex items-center gap-2 border border-white/5 bg-white/[0.04] px-1.5 ${className}`}>
|
||||
<span className="size-6 animate-pulse rounded-full bg-white/10" />
|
||||
<span className="h-2.5 flex-1 animate-pulse rounded-full bg-white/10" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (!me) {
|
||||
return (
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={onNavigate}
|
||||
className={`${SLOT} flex items-center justify-center border border-white/12 bg-white/[0.04] text-sm font-semibold text-snow transition-colors hover:border-white/25 hover:bg-white/[0.08] ${className}`}
|
||||
>
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={onNavigate}
|
||||
className={`${SLOT} flex min-w-0 items-center gap-2 border border-white/10 bg-white/[0.04] pl-1 pr-3 text-sm text-snow transition-colors hover:border-white/25 ${className}`}
|
||||
>
|
||||
<Avatar me={me} />
|
||||
<span className="min-w-0 truncate">{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-ice/20 font-display text-xs font-bold text-ice">
|
||||
{me.display_nick.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownloadIcon({ className = 'size-4' }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className={className} aria-hidden="true">
|
||||
<path d="M10 3v10m0 0-4-4m4 4 4-4M4 16h12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Three bars that fold into a cross while the menu is open. */
|
||||
export function MenuIcon({ open }: { open: boolean }) {
|
||||
const bar = 'absolute left-0 h-[2px] w-full rounded-full bg-current transition-transform duration-300'
|
||||
return (
|
||||
<span aria-hidden="true" className="relative block h-3 w-4">
|
||||
<span className={`${bar} top-0 ${open ? 'translate-y-[5px] rotate-45' : ''}`} />
|
||||
<span className={`${bar} top-[5px] transition-opacity ${open ? 'opacity-0' : ''}`} />
|
||||
<span className={`${bar} top-[10px] ${open ? '-translate-y-[5px] -rotate-45' : ''}`} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue