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:
loki5512344 2026-09-26 15:26:37 +02:00
parent 7f4b532f99
commit ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions

View 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>
)
}

View 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 |_| \\___/ |_|"

View 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',
)
}

View 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)
})
})

View 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
}

View file

@ -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>

View file

@ -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',
},

View file

@ -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',
},

View file

@ -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 />

View file

@ -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">

View file

@ -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>
)
}

View file

@ -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, '/')

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}