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

@ -1,12 +1,22 @@
import i18next, { type i18n } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import { accountEn } from '../features/account/i18n/en'
import { accountRu } from '../features/account/i18n/ru'
import { authEn } from '../features/auth/i18n/en'
import { authRu } from '../features/auth/i18n/ru'
import { clickguiEn } from '../features/clickgui/i18n/en'
import { configsEn } from '../features/configs/i18n/en'
import { clickguiRu } from '../features/clickgui/i18n/ru'
import { configsRu } from '../features/configs/i18n/ru'
import { deviceLinkEn } from '../features/device-link/i18n/en'
import { deviceLinkRu } from '../features/device-link/i18n/ru'
import { downloadEn } from '../features/download/i18n/en'
import { profileEn } from '../features/profile/i18n/en'
import { profileRu } from '../features/profile/i18n/ru'
import { showcaseEn } from '../features/showcase/i18n/en'
import { downloadRu } from '../features/download/i18n/ru'
import { showcaseRu } from '../features/showcase/i18n/ru'
import { landingEn } from '../features/landing/i18n/en'
import { landingRu } from '../features/landing/i18n/ru'
import { themesEn } from '../features/themes/i18n/en'
@ -23,12 +33,36 @@ export function initI18n(lng?: 'ru' | 'en'): i18n {
.init({
lng,
resources: {
ru: { common: commonRu, auth: authRu, landing: landingRu, themes: themesRu, clickgui: clickguiRu, download: downloadRu },
en: { common: commonEn, auth: authEn, landing: landingEn, themes: themesEn, clickgui: clickguiEn, download: downloadEn },
ru: {
common: commonRu,
auth: authRu,
landing: landingRu,
themes: themesRu,
clickgui: clickguiRu,
download: downloadRu,
deviceLink: deviceLinkRu,
account: accountRu,
configs: configsRu,
showcase: showcaseRu,
profile: profileRu,
},
en: {
common: commonEn,
auth: authEn,
landing: landingEn,
themes: themesEn,
clickgui: clickguiEn,
download: downloadEn,
deviceLink: deviceLinkEn,
account: accountEn,
configs: configsEn,
showcase: showcaseEn,
profile: profileEn,
},
},
fallbackLng: 'ru',
supportedLngs: ['ru', 'en'],
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'],
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'],
defaultNS: 'common',
interpolation: { escapeValue: false },
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },

View file

@ -1,7 +1,12 @@
import type { accountRu } from '../features/account/i18n/ru'
import type { authRu } from '../features/auth/i18n/ru'
import type { clickguiRu } from '../features/clickgui/i18n/ru'
import type { configsRu } from '../features/configs/i18n/ru'
import type { deviceLinkRu } from '../features/device-link/i18n/ru'
import type { downloadRu } from '../features/download/i18n/ru'
import type { landingRu } from '../features/landing/i18n/ru'
import type { profileRu } from '../features/profile/i18n/ru'
import type { showcaseRu } from '../features/showcase/i18n/ru'
import type { themesRu } from '../features/themes/i18n/ru'
import type { commonRu } from '../shared/i18n/common.ru'
@ -15,6 +20,11 @@ declare module 'i18next' {
themes: typeof themesRu
clickgui: typeof clickguiRu
download: typeof downloadRu
deviceLink: typeof deviceLinkRu
account: typeof accountRu
configs: typeof configsRu
showcase: typeof showcaseRu
profile: typeof profileRu
// Later tasks extend this with their own feature namespaces.
}
}

View file

@ -4,10 +4,16 @@ import { AppShell } from '../shared/layout/AppShell'
import HomePage from '../pages/public/HomePage'
import LoginPage from '../pages/public/LoginPage'
import RegisterPage from '../pages/public/RegisterPage'
import ProfilePage from '../pages/public/ProfilePage'
import NotFoundPage from '../pages/public/NotFoundPage'
// Code-split: the download page pulls in react-markdown, so it loads on demand.
import DownloadRoute from '../pages/download/DownloadRoute'
import AccountPage from '../pages/app/AccountPage'
import LinkPage from '../pages/app/LinkPage'
import ThemesRoute from '../pages/themes/ThemesRoute'
import ConfigsPage from '../pages/app/ConfigsPage'
import ShowcaseRoute from '../pages/showcase/ShowcaseRoute'
import ListingRoute from '../pages/showcase/ListingRoute'
export const routes: RouteObject[] = [
{
@ -18,7 +24,17 @@ export const routes: RouteObject[] = [
{ path: '/register', element: <RegisterPage /> },
{ path: '/download', element: <DownloadRoute /> },
{ path: '/themes', element: <ThemesRoute /> },
{ element: <RequireAuth />, children: [] }, // Tasks 6–8 add /link, /account, /configs
{ path: '/u/:id', element: <ProfilePage /> },
{ path: '/showcase', element: <ShowcaseRoute /> },
{ path: '/showcase/:id', element: <ListingRoute /> },
{
element: <RequireAuth />,
children: [
{ path: '/link', element: <LinkPage /> },
{ path: '/account', element: <AccountPage /> },
{ path: '/configs', element: <ConfigsPage /> },
],
},
{ path: '*', element: <NotFoundPage /> },
],
},

View file

@ -0,0 +1,27 @@
import { api } from '../../shared/api/client'
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }
const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])
const MAX_BYTES = 5 * 1024 * 1024
/** Client-side avatar checks so a bad file never reaches the server. */
export function checkAvatarFile(file: File): string | undefined {
if (!ALLOWED_TYPES.has(file.type)) return 'Подойдёт PNG, JPG или WebP.'
if (file.size > MAX_BYTES) return 'Файл больше 5 МБ.'
return undefined
}
export async function uploadAvatar(file: File): Promise<{ avatar_url: string }> {
const body = new FormData()
body.set('file', file)
return api.request('/avatars', { method: 'POST', body })
}
export async function listDevices(): Promise<DeviceLink[]> {
return api.request('/device/links')
}
export async function revokeDevice(id: string): Promise<void> {
await api.request(`/device/links/${id}`, { method: 'DELETE' })
}

View file

@ -0,0 +1,49 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Notice } from '../../../shared/ui/Notice'
import { checkAvatarFile, uploadAvatar } from '../api'
/** A label-based file picker (keyboard reachable) that uploads and refreshes the cached profile. */
export function AvatarUpload() {
const { t } = useTranslation('account')
const id = useId()
const client = useQueryClient()
const [error, setError] = useState<string>()
const mutation = useMutation({
mutationFn: uploadAvatar,
onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }),
})
function onChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const problem = checkAvatarFile(file)
if (problem) {
setError(problem)
return
}
setError(undefined)
mutation.mutate(file)
}
return (
<div className="flex flex-col items-center gap-2">
<label
htmlFor={id}
className="cursor-pointer rounded-md border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
>
{t('avatar.change')}
<input
id={id}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={onChange}
/>
</label>
{error ? <Notice tone="error">{error}</Notice> : null}
</div>
)
}

View file

@ -0,0 +1,72 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { listDevices, revokeDevice, type DeviceLink } from '../api'
function relativeDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
function DeviceRow({ device }: { device: DeviceLink }) {
const { t } = useTranslation('account')
const client = useQueryClient()
const [confirming, setConfirming] = useState(false)
const mutation = useMutation({
mutationFn: () => revokeDevice(device.id),
onSuccess: () => client.invalidateQueries({ queryKey: ['devices'] }),
})
return (
<li className="flex items-center justify-between gap-4 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-3">
<div>
<p className="text-sm text-snow">{t('devices.linkedAt', { date: relativeDate(device.linked_at) })}</p>
<p className="text-sm text-frost">
{t('devices.lastSeen', { when: device.last_seen ? relativeDate(device.last_seen) : t('devices.neverSeen') })}
</p>
</div>
{confirming ? (
<div className="flex items-center gap-2">
<Button variant="danger" busy={mutation.isPending} onClick={() => mutation.mutate()}>
{t('devices.confirmRevoke')}
</Button>
<Button variant="quiet" onClick={() => setConfirming(false)}>
{t('devices.cancel')}
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirming(true)}>
{t('devices.revoke')}
</Button>
)}
</li>
)
}
/** ClickGui-like rows listing devices the mod linked, each with a two-step confirm to revoke. */
export function DeviceList() {
const { t } = useTranslation('account')
const query = useQuery({ queryKey: ['devices'], queryFn: listDevices })
const devices = query.data ?? []
return (
<div className="rounded-(--radius-panel) border border-shoal bg-slate/40 p-5">
<h2 className="font-display text-lg">{t('devices.heading')}</h2>
{devices.length === 0 ? (
<p className="mt-3 text-sm text-frost">
{t('devices.empty')}{' '}
<Link to="/link" className="text-ice underline-offset-4 hover:underline">
{t('devices.emptyLink')}
</Link>
</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{devices.map((device) => (
<DeviceRow key={device.id} device={device} />
))}
</ul>
)}
</div>
)
}

View file

@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { useSession } from '../../auth/session'
import { AvatarUpload } from './AvatarUpload'
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. */
export function ProfileCard() {
const { t } = useTranslation('account')
const { me, signOut } = useSession()
const navigate = useNavigate()
if (!me) return null
const joined = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(me.created_at))
async function handleSignOut() {
await signOut()
navigate('/')
}
return (
<div className="flex flex-col items-center gap-4 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6 text-center">
<div className="relative">
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
{me.avatar_url ? (
<img src={me.avatar_url} alt="" className="h-full w-full object-cover" />
) : (
<span aria-hidden="true">{me.display_nick.slice(0, 1).toUpperCase()}</span>
)}
</div>
</div>
<AvatarUpload />
<div>
<p className="font-display text-xl">{me.display_nick}</p>
<p className="text-sm text-frost">{me.email}</p>
<p className="mt-1 text-sm text-frost">{t('profile.memberSince', { date: joined })}</p>
</div>
<Button variant="quiet" onClick={handleSignOut}>
{t('profile.signOut')}
</Button>
</div>
)
}

View file

@ -0,0 +1,23 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { accountRu } from './ru'
export const accountEn = {
profile: {
memberSince: 'With us since {{date}}',
signOut: 'Sign out',
},
avatar: {
change: 'Change avatar',
},
devices: {
heading: 'Linked games',
linkedAt: 'Linked {{date}}',
lastSeen: 'Last seen: {{when}}',
neverSeen: 'never signed in yet',
revoke: 'Unlink',
confirmRevoke: 'Really unlink?',
cancel: 'Cancel',
empty: 'No linked games yet. Link the mod on the "Link the game" page.',
emptyLink: 'Link the game',
},
} satisfies Translation<typeof accountRu>

View file

@ -0,0 +1,20 @@
export const accountRu = {
profile: {
memberSince: 'С нами с {{date}}',
signOut: 'Выйти',
},
avatar: {
change: 'Сменить аватар',
},
devices: {
heading: 'Привязанные игры',
linkedAt: 'Привязано {{date}}',
lastSeen: 'Последний вход: {{when}}',
neverSeen: 'ещё не заходил',
revoke: 'Отвязать',
confirmRevoke: 'Точно отвязать?',
cancel: 'Отмена',
empty: 'Пока ни одной игры. Привяжи мод на странице «Привязать игру».',
emptyLink: 'Привязать игру',
},
} as const

View file

@ -0,0 +1,33 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { checkAvatarFile } from '../api'
import { DeviceList } from '../components/DeviceList'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
test('avatar file checks', () => {
expect(checkAvatarFile(new File(['x'], 'a.gif', { type: 'image/gif' }))).toBe('Подойдёт PNG, JPG или WebP.')
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'a.png', { type: 'image/png' })
expect(checkAvatarFile(big)).toBe('Файл больше 5 МБ.')
expect(checkAvatarFile(new File(['x'], 'a.png', { type: 'image/png' }))).toBeUndefined()
})
test('revoking a device asks for confirmation first', async () => {
const user = userEvent.setup()
let devices = [{ id: 'd1', linked_at: '2026-09-20T10:00:00Z', last_seen: null }]
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /device/links': () => json(devices),
'DELETE /device/links/d1': () => {
devices = []
return new Response(null, { status: 204 })
},
})
renderApp([{ path: '/', element: <DeviceList /> }])
const row = await screen.findByRole('listitem')
await user.click(within(row).getByRole('button', { name: 'Отвязать' }))
expect(fetch.mock.calls.some(([, i]) => i?.method === 'DELETE')).toBe(false)
await user.click(within(row).getByRole('button', { name: 'Точно отвязать?' }))
expect(await screen.findByText(/Пока ни одной игры/)).toBeInTheDocument()
})

View file

@ -3,6 +3,7 @@ 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.',
login: {
title: 'Log in',
emailLabel: 'Email',

View file

@ -1,5 +1,6 @@
export const authRu = {
checkingSession: 'Проверяем вход…',
splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.',
login: {
title: 'Вход',
emailLabel: 'Почта',

View file

@ -1,14 +1,16 @@
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
import { PRESETS } from '../themes/presets.generated'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'
import { DEMO, useDemo } from './demo'
import { ModuleCard } from './ModuleCard'
import './parts/clickgui.css'
import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs'
import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules'
import { Backdrop } from './parts/Backdrop'
import { DemoCursor, Icon } from './parts/Glyphs'
import { QUICK_THEMES, TABS } from './parts/modules'
import { ModulesScreen } from './screens/ModulesScreen'
import './screens/screens.css'
import { SettingsScreen } from './screens/SettingsScreen'
import { themeToCssVars } from './themeVars'
type Props = {
@ -17,16 +19,43 @@ type Props = {
paused?: boolean
/** Overrides the active theme — for previews. */
theme?: ThemeEntry
/**
* Controlled theme picking: when set, theme cards in Settings call this instead of switching the
* site-wide theme (the /themes editor loads the preset into its draft).
*/
onPickTheme?: (id: string) => void
/** Image URL painted over the procedural backdrop (e.g. a real in-game F1 screenshot). */
backdrop?: string
className?: string
}
export function ClickGuiWindow({ demo = true, paused = false, theme: override, className = '' }: Props) {
const HINTS = {
modules: ['toggle', 'settings', 'moduleList', 'search', 'close', 'hide'],
settings: ['pick', 'edit', 'wheel', 'close', 'hide'],
} as const
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
}
/**
* A faithful replica of the in-game ClickGui: the top pill bar (clock + Modules/Settings switch),
* the four glass category panels, and the Settings → Темы page whose cards re-theme everything.
*/
export function ClickGuiWindow({ demo = true, paused = false, theme: override, onPickTheme, backdrop, className = '' }: Props) {
const { t } = useTranslation('clickgui')
const { theme: active, setTheme } = useActiveTheme()
const theme = override ?? active
const rootRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
const [reduced] = useState(prefersReducedMotion)
const clock = useClock()
const themeIdRef = useRef(theme.id)
useEffect(() => {
themeIdRef.current = theme.id
@ -40,11 +69,14 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c
const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number])
return QUICK_THEMES[(i + 1) % QUICK_THEMES.length]
},
onTheme: (id) => setTheme(id, 'demo'),
onTheme: (id) => (onPickTheme ? onPickTheme(id) : setTheme(id, 'demo')),
})
const vars = useMemo(() => themeToCssVars(theme), [theme])
const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0]
const vars = useMemo(
() => (backdrop ? { ...themeToCssVars(theme), '--gui-backdrop-image': `url(${JSON.stringify(backdrop)})` } : themeToCssVars(theme)),
[theme, backdrop],
)
const pick = onPickTheme ?? ((id: string) => setTheme(id, 'user'))
return (
<div
@ -54,75 +86,59 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c
data-testid="clickgui-root"
className={`lv-gui ${className}`}
style={vars}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHovered(true)}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<div className="lv-gui-bloom" aria-hidden="true" />
<div className="lv-gui-window">
<header className="lv-gui-header">
<span className="lv-gui-logo">
<LogoMark />
LoVisual
</span>
<nav className="lv-gui-tabs" aria-hidden="true">
{TABS.map((tab, i) => (
<span key={tab} className="lv-gui-tab" data-active={i === 0}>
{tab}
</span>
))}
</nav>
<span className="lv-gui-search" aria-hidden="true">
<SearchGlyph />
</span>
</header>
<div className="lv-gui-stage" data-tab={state.tab}>
<div className="lv-gui-world" aria-hidden="true">
<Backdrop />
<div className="lv-gui-photo" />
</div>
<p className="lv-gui-watermark" aria-hidden="true">
<b>LoVisual</b>·400 fps <span>0 ms</span>
</p>
<div className="lv-gui-body">
<aside className="lv-gui-cats" aria-label={t('categories')}>
{CATEGORIES.map((c) => (
<div className="lv-gui-bar">
<time className="lv-gui-clock" aria-label={t('clock')}>
{clock}
</time>
<div className="lv-gui-tabs" role="tablist" aria-label={t('tabsLabel')}>
<span className="lv-gui-tab-thumb" aria-hidden="true" />
{TABS.map((id) => (
<button
key={c.id}
key={id}
type="button"
className="lv-gui-cat"
data-active={c.id === category.id}
aria-pressed={c.id === category.id}
data-demo={`cat:${c.id}`}
aria-label={c.name}
onClick={() => dispatch({ type: 'category', id: c.id })}
role="tab"
className="lv-gui-tab"
aria-selected={state.tab === id}
data-demo={`tab:${id}`}
onClick={() => dispatch({ type: 'tab', id })}
>
<CategoryIcon id={c.id} />
<span className="lv-gui-cat-name">{c.name}</span>
<span className="lv-gui-cat-count">{c.modules.length}</span>
{t(`tabs.${id}`)}
</button>
))}
<div className="lv-gui-theme-dots">
{QUICK_THEMES.map((id) => {
const preset = PRESETS.find((p) => p.id === id)
if (!preset) return null
const dot = themeToCssVars(preset)
return (
<button
key={id}
type="button"
className="lv-gui-theme-dot"
data-active={theme.id === id}
data-demo={`theme:${id}`}
aria-label={t('theme', { name: preset.name })}
style={{ '--dot': dot['--gui-header-gradient'], '--dot-accent': dot['--gui-accent'] } as CSSProperties}
onClick={() => setTheme(id, 'user')}
/>
)
})}
</div>
</aside>
<div className="lv-gui-main">
<div className="lv-gui-grid" key={category.id}>
{category.modules.map((m) => (
<ModuleCard key={m.name} module={m} state={state} dispatch={dispatch} />
))}
</div>
</div>
</div>
{state.tab === 'modules' && (
<button type="button" className="lv-gui-search" aria-label={t('search')}>
<Icon name="search" className="lv-gui-search-icon" />
</button>
)}
<div className="lv-gui-screen" key={state.tab}>
{state.tab === 'modules' ? (
<ModulesScreen state={state} dispatch={dispatch} />
) : (
<SettingsScreen activeId={theme.id} onPick={pick} />
)}
</div>
<ul className="lv-gui-hints" aria-hidden="true">
{HINTS[state.tab].map((k) => (
<li key={k}>{t(`hints.${state.tab}.${k}` as `hints.modules.toggle`)}</li>
))}
</ul>
</div>
<DemoCursor cursor={cursor} />
</div>

View file

@ -1,59 +0,0 @@
import type { Dispatch } from 'react'
import { useTranslation } from 'react-i18next'
import type { GuiAction, GuiState } from './demo'
import { Chevron } from './parts/Glyphs'
import type { GuiModule } from './parts/modules'
import { SettingControls } from './parts/SettingControls'
type Props = { module: GuiModule; state: GuiState; dispatch: Dispatch<GuiAction> }
/** One module card: bullet title, description, divider, bind + settings chevron + status toggle. */
export function ModuleCard({ module, state, dispatch }: Props) {
const { t } = useTranslation('clickgui')
const { name, settings } = module
const on = Boolean(state.enabled[name])
const open = state.expanded === name && Boolean(settings)
return (
<article className="lv-gui-card" data-on={on} data-open={open}>
<span className="lv-gui-card-glow" aria-hidden="true" />
<h3 className="lv-gui-card-title">
<span aria-hidden="true">• </span>
{name}
</h3>
<p className="lv-gui-card-desc">{t(`desc.${name}`)}</p>
<div className="lv-gui-card-divider" aria-hidden="true" />
<div className="lv-gui-card-foot">
<span className="lv-gui-bind">
{t('bind')}: <span>{t('bindNone')}</span>
</span>
{settings ? (
<button
type="button"
className="lv-gui-expand"
aria-expanded={open}
aria-label={t('settings', { name })}
data-demo={`expand:${name}`}
onClick={() => dispatch({ type: 'expand', module: name })}
>
<Chevron open={open} />
</button>
) : (
<span className="lv-gui-bind lv-gui-nosettings">{t('noSettings')}</span>
)}
<button
type="button"
role="switch"
aria-checked={on}
aria-label={t('toggle', { name })}
data-demo={`toggle:${name}`}
className="lv-gui-toggle"
onClick={() => dispatch({ type: 'toggle', module: name })}
>
<span className="lv-gui-toggle-knob" />
</button>
</div>
{open && settings ? <SettingControls module={name} settings={settings} state={state} dispatch={dispatch} /> : null}
</article>
)
}

View file

@ -1,73 +1,46 @@
import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react'
import { CATEGORIES, type CategoryId, settingKey } from './parts/modules'
import { CATEGORIES, type TabId } from './parts/modules'
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'theme'; target: string }
export const LOOP_MS = 12_500
export const LOOP_MS = 11_000
/** The scripted loop: pick Visuals, switch Trails on, open it, drag Length, tick Rainbow, change theme, tidy up. */
/** The scripted loop: toggle two modules, open Settings, pick a theme card, come back to Modules. */
export const DEMO: DemoStep[] = [
{ at: 400, action: 'move', target: 'cat:visuals' },
{ at: 1100, action: 'click', target: 'cat:visuals' },
{ at: 1700, action: 'move', target: 'toggle:Trails' },
{ at: 2400, action: 'click', target: 'toggle:Trails' },
{ at: 3000, action: 'move', target: 'expand:Trails' },
{ at: 3600, action: 'click', target: 'expand:Trails' },
{ at: 4300, action: 'move', target: 'knob:Trails.Length' },
{ at: 4900, action: 'drag', target: 'slider:Trails.Length', value: 85 },
{ at: 6300, action: 'move', target: 'check:Trails.Rainbow' },
{ at: 6900, action: 'click', target: 'check:Trails.Rainbow' },
{ at: 7700, action: 'move', target: 'theme:next' },
{ at: 8400, action: 'theme', target: 'theme:next' },
{ at: 9400, action: 'move', target: 'expand:Trails' },
{ at: 10000, action: 'click', target: 'expand:Trails' },
{ at: 10800, action: 'move', target: 'rest' },
{ at: 400, action: 'move', target: 'module:AmbientParticles' },
{ at: 1100, action: 'click', target: 'module:AmbientParticles' },
{ at: 1800, action: 'move', target: 'module:DamageTint' },
{ at: 2500, action: 'click', target: 'module:DamageTint' },
{ at: 3300, action: 'move', target: 'tab:settings' },
{ at: 4000, action: 'click', target: 'tab:settings' },
{ at: 4800, action: 'move', target: 'theme:next' },
{ at: 5600, action: 'theme', target: 'theme:next' },
{ at: 7200, action: 'move', target: 'tab:modules' },
{ at: 7900, action: 'click', target: 'tab:modules' },
{ at: 8800, action: 'move', target: 'rest' },
]
export type GuiState = {
category: CategoryId
enabled: Record<string, boolean>
expanded: string | null
values: Record<string, number | boolean | string>
dragging: boolean
}
/** Modules the demo switches on — also enabled in the static reduced-motion frame. */
const DEMO_TOGGLES = ['AmbientParticles', 'DamageTint']
export type GuiAction =
| { type: 'category'; id: CategoryId }
| { type: 'toggle'; module: string }
| { type: 'expand'; module: string }
| { type: 'set'; key: string; value: number | boolean | string }
| { type: 'dragging'; on: boolean }
| { type: 'reset' }
export type GuiState = { tab: TabId; enabled: Record<string, boolean> }
export type GuiAction = { type: 'tab'; id: TabId } | { type: 'toggle'; module: string } | { type: 'reset' }
export function initialGuiState(finished = false): GuiState {
const enabled: Record<string, boolean> = {}
const values: GuiState['values'] = {}
for (const c of CATEGORIES) {
for (const m of c.modules) {
enabled[m.name] = Boolean(m.on)
for (const s of m.settings ?? []) values[settingKey(m.name, s.name)] = s.value
}
}
if (!finished) return { category: 'combat', enabled, expanded: null, values, dragging: false }
for (const c of CATEGORIES) for (const m of c.modules) enabled[m.name] = Boolean(m.on)
// The resting "after the demo" frame, used under reduced motion.
enabled.Trails = true
values[settingKey('Trails', 'Length')] = 85
return { category: 'visuals', enabled, expanded: 'Trails', values, dragging: false }
if (finished) for (const name of DEMO_TOGGLES) enabled[name] = true
return { tab: 'modules', enabled }
}
export function guiReducer(state: GuiState, action: GuiAction): GuiState {
switch (action.type) {
case 'category':
return { ...state, category: action.id, expanded: null }
case 'tab':
return { ...state, tab: action.id }
case 'toggle':
return { ...state, enabled: { ...state.enabled, [action.module]: !state.enabled[action.module] } }
case 'expand':
return { ...state, expanded: state.expanded === action.module ? null : action.module }
case 'set':
return { ...state, values: { ...state.values, [action.key]: action.value } }
case 'dragging':
return { ...state, dragging: action.on }
case 'reset':
return initialGuiState()
}
@ -84,17 +57,17 @@ type Options = {
onTheme: (id: string) => void
}
/** Layout position of `el` inside `root`, ignoring CSS transforms (the window is tilted in 3D). */
function offsetWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
let x = 0
let y = 0
let node: HTMLElement | null = el
while (node && node !== root) {
x += node.offsetLeft
y += node.offsetTop
node = node.offsetParent as HTMLElement | null
/** Visual position of `el` inside `root` in root-local pixels: rects see CSS transforms (the tab bar's
* `translateX(-50%)`, the screen's entrance animation) that the `offsetLeft` chain silently skips.
* `scale` undoes transforms applied by ancestors of the root (the hero's entrance scale). */
function positionWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
const rr = root.getBoundingClientRect()
const tr = el.getBoundingClientRect()
const scale = root.offsetWidth && rr.width ? rr.width / root.offsetWidth : 1
return {
x: (tr.left - rr.left) / scale + (tr.width / scale) * 0.62,
y: (tr.top - rr.top) / scale + (tr.height / scale) * 0.5,
}
return { x, y }
}
export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } {
@ -111,29 +84,40 @@ export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; sta
const timers: number[] = []
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
let pendingTheme = ''
/** When the pointer finishes its current travel; a press must not fire before that. */
let travelEndsAt = 0
const locate = (target: string, fraction?: number) => {
const locate = (target: string) => {
const root = optsRef.current.rootRef.current
if (!root) return null
if (target === 'rest') return { x: root.offsetWidth * 0.86, y: root.offsetHeight * 0.9 }
const resolved = target === 'theme:next' ? (pendingTheme ||= `theme:${optsRef.current.nextTheme()}`) : target
const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`)
if (!el) return null
const { x, y } = offsetWithin(el, root)
const fx = fraction ?? 0.5
return { x: x + el.offsetWidth * fx, y: y + el.offsetHeight * 0.5 }
return positionWithin(el, root)
}
const move = (target: string, duration: number, fraction?: number) => {
const p = locate(target, fraction)
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration }))
const move = (target: string, duration: number) => {
const p = locate(target)
if (!p) return
travelEndsAt = performance.now() + duration
setCursor((c) => ({ ...c, ...p, visible: true, duration }))
}
const click = (fn: () => void) => {
setCursor((c) => ({ ...c, pressed: true }))
later(160, () => {
setCursor((c) => ({ ...c, pressed: false }))
fn()
/** Press `target` only once the pointer is actually on it: timers trail the CSS travel, and a
* target that moved or unmounted would mean a click on air. */
const click = (target: string, fn: () => void) => {
later(Math.max(0, travelEndsAt - performance.now()) + 60, () => {
const p = locate(target)
if (!p) return
setCursor((c) => ({ ...c, ...p, visible: true, duration: 0 }))
later(60, () => {
setCursor((c) => ({ ...c, pressed: true }))
later(160, () => {
setCursor((c) => ({ ...c, pressed: false }))
fn()
})
})
})
}
@ -141,25 +125,11 @@ export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; sta
const [kind, arg] = step.target.split(':')
if (step.action === 'move') return move(step.target, 650)
if (step.action === 'theme') {
return click(() => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
return click(step.target, () => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
}
if (step.action === 'drag') {
const [module, setting] = arg.split('.')
const value = Number(step.value ?? 0)
setCursor((c) => ({ ...c, pressed: true }))
dispatch({ type: 'dragging', on: true })
move(step.target, 900, value / 100)
dispatch({ type: 'set', key: settingKey(module, setting), value })
return later(950, () => {
setCursor((c) => ({ ...c, pressed: false }))
dispatch({ type: 'dragging', on: false })
})
}
click(() => {
if (kind === 'cat') dispatch({ type: 'category', id: arg as CategoryId })
if (kind === 'toggle') dispatch({ type: 'toggle', module: arg })
if (kind === 'expand') dispatch({ type: 'expand', module: arg })
if (kind === 'check') dispatch({ type: 'set', key: arg, value: true })
click(step.target, () => {
if (kind === 'tab') dispatch({ type: 'tab', id: arg as TabId })
if (kind === 'module') dispatch({ type: 'toggle', module: arg })
})
}

View file

@ -3,47 +3,18 @@ import type { clickguiRu } from './ru'
export const clickguiEn = {
windowLabel: 'Interactive replica of the LoVisual ClickGui',
bind: 'Bind',
bindNone: 'none',
toggle: 'Toggle {{name}}',
settings: '{{name}} settings',
categories: 'Categories',
tabsLabel: 'ClickGui sections',
tabs: { modules: 'Modules', settings: 'Settings' },
clock: 'Time',
search: 'Search',
categories: 'Module categories',
module: '{{name}}: LMB toggles it',
theme: '{{name}} theme',
noSettings: 'No settings',
desc: {
PvpCooldowns: 'Cooldown timers for shield, pearls and totems',
Predictions: 'Flight path of pearls and arrows',
TargetESP: 'Animated marker on your current target',
DamageTint: 'A colored flash when you take damage',
NoFriendDamage: 'Stops you from hitting friends by accident',
CombatLog: 'Hit and damage log in chat',
TPSSync: 'Syncs timers to the server TPS',
ProjectilePuncher: 'Knocks away incoming projectiles',
Trails: 'A glowing trail behind the player',
JumpCircles: 'Rings on the ground when you jump',
HitBubbles: 'Damage bubbles on every hit',
AmbientParticles: 'Soft particles floating around you',
Wings: 'Animated wings on your back',
ChinaHat: 'A cone hat with a gradient',
ShaderSky: 'A shader sky with clouds',
MotionBlur: 'Blur while you move',
PostFX: 'Bloom, vignette and color grading',
KillEffect: 'A lightning strike on kills',
ThirdPerson: 'A smooth third-person camera',
AutoTool: 'Picks the right tool for you',
DeathCoords: 'Death coordinates in chat',
AutoRespawn: 'Respawn without the death screen',
ClickPearl: 'Throw a pearl with a mouse button',
LockSlot: 'Protects slots from dropping',
UseTracker: 'Counts item usage',
NoDelay: 'Removes the jump delay',
StreamerMode: 'Hides personal info on stream',
NameProtect: 'Replaces your name on screen',
HitSounds: 'Pick your own hit sound',
ItemScroller: 'Move items around faster',
MessageFilter: 'Filters chat spam',
Optimize: 'Extra FPS without losing the looks',
NoServerPack: 'Declines server resource packs',
BetterMinecraft: 'Small interface improvements',
themes: 'Themes',
settingsNav: 'Settings sections',
sidebar: { hud: 'HUD', ui: 'UI', themes: 'Themes', layout: 'Layout', account: 'Account', configs: 'Configs' },
hints: {
modules: { toggle: 'LMB - toggle module', settings: 'RMB - settings', moduleList: 'E - edit ModuleList', search: 'Ctrl+F - search', close: 'Esc - close ClickGUI', hide: 'Alt+H - hide hints' },
settings: { pick: 'LMB - pick a theme', edit: 'Edit - open the theme editor', wheel: 'Wheel - scroll themes', close: 'Esc - close ClickGUI', hide: 'Alt+H - hide hints' },
},
} satisfies Translation<typeof clickguiRu>

View file

@ -1,46 +1,17 @@
export const clickguiRu = {
windowLabel: 'Интерактивная копия ClickGui LoVisual',
bind: 'Бинд',
bindNone: 'нет',
toggle: 'Включить {{name}}',
settings: 'Настройки {{name}}',
categories: 'Категории',
tabsLabel: 'Разделы ClickGui',
tabs: { modules: 'Modules', settings: 'Settings' },
clock: 'Время',
search: 'Поиск',
categories: 'Категории модулей',
module: '{{name}}: ЛКМ: включить или выключить',
theme: 'Тема {{name}}',
noSettings: 'Без настроек',
desc: {
PvpCooldowns: 'Таймеры перезарядки щита, жемчуга и тотема',
Predictions: 'Траектория полёта жемчуга и стрел',
TargetESP: 'Анимированная метка на текущей цели',
DamageTint: 'Цветная вспышка при получении урона',
NoFriendDamage: 'Не даёт случайно ударить друга',
CombatLog: 'Лог попаданий и урона в чате',
TPSSync: 'Подстраивает таймеры под TPS сервера',
ProjectilePuncher: 'Сбивает летящие в тебя снаряды',
Trails: 'Светящийся шлейф за игроком',
JumpCircles: 'Круги на земле при прыжке',
HitBubbles: 'Пузыри с уроном при ударе',
AmbientParticles: 'Мягкие частицы вокруг тебя',
Wings: 'Анимированные крылья за спиной',
ChinaHat: 'Конусная шляпа с градиентом',
ShaderSky: 'Шейдерное небо с облаками',
MotionBlur: 'Размытие в движении',
PostFX: 'Bloom, виньетка и цветокоррекция',
KillEffect: 'Эффект молнии при убийстве',
ThirdPerson: 'Плавная камера от третьего лица',
AutoTool: 'Сам берёт нужный инструмент',
DeathCoords: 'Координаты смерти в чате',
AutoRespawn: 'Возрождение без экрана смерти',
ClickPearl: 'Жемчуг по кнопке мыши',
LockSlot: 'Защищает слоты от выброса',
UseTracker: 'Счётчик использования предметов',
NoDelay: 'Убирает задержку прыжка',
StreamerMode: 'Прячет личные данные на стриме',
NameProtect: 'Заменяет твой ник на экране',
HitSounds: 'Звук попадания на выбор',
ItemScroller: 'Быстрое перекладывание предметов',
MessageFilter: 'Фильтр спама в чате',
Optimize: 'Лишние FPS без потери красоты',
NoServerPack: 'Отказ от серверного ресурспака',
BetterMinecraft: 'Мелкие улучшения интерфейса',
themes: 'Темы',
settingsNav: 'Разделы настроек',
sidebar: { hud: 'HUD', ui: 'UI', themes: 'Темы', layout: 'Расположение', account: 'Аккаунт', configs: 'Конфиги' },
hints: {
modules: { toggle: 'ЛКМ - включить/выключить модуль', settings: 'ПКМ - настройки', moduleList: 'E - редактировать ModuleList', search: 'Ctrl+F - поиск', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
settings: { pick: 'ЛКМ - выбрать тему', edit: 'Edit - открыть редактор темы', wheel: 'Колесо - листать темы', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
},
} as const

View file

@ -0,0 +1,128 @@
import { memo } from 'react'
/**
* The world behind the ClickGui: a night sky over a frozen lake, drawn as one small SVG
* (no images). Horizon at ~46%; the lake starts as dark as the sky and brightens towards the
* viewer, with a mist band over the shoreline, so there is no hard edge behind the panels.
* A real screenshot can replace it: set `--gui-backdrop-image` (or ClickGuiWindow's `backdrop`),
* and `.lv-gui-photo` covers this scene.
*/
/** Deterministic pseudo-random sequence, so the sky is identical on every render and in SSR. */
function rng(seed: number) {
let s = seed
return () => {
s = (s * 16807) % 2147483647
return (s - 1) / 2147483646
}
}
const W = 560
const HORIZON = 150
const rand = rng(7)
const STARS = Array.from({ length: 80 }, () => ({
x: rand() * W,
y: rand() * (HORIZON - 30),
r: rand() < 0.12 ? 0.9 : 0.45 + rand() * 0.35,
o: 0.35 + rand() * 0.6,
twinkle: rand() < 0.2,
}))
const FLAKES = Array.from({ length: 26 }, () => ({ x: rand() * W, y: rand() * 300, s: 0.8 + rand() * 1.1, d: 9 + rand() * 9 }))
/** A Minecraft spruce: a trunk and stacked needle tiers that alternate wide/narrow, snow on each tier. */
function Spruce({ x, base, h }: { x: number; base: number; h: number }) {
const u = h / 14
const tiers = [5, 3.5, 4.5, 3, 3.5, 2, 2.5, 1, 1]
return (
<g>
<rect x={x - u / 2} y={base - 2 * u} width={u} height={2 * u} fill="#1a1210" />
{tiers.map((half, i) => {
const y = base - (i + 2) * u - u * 0.35
return (
<g key={i}>
<rect x={x - half * u} y={y} width={half * 2 * u} height={u * 1.05} fill={i % 2 ? '#0c1b1d' : '#0f2224'} />
{i % 2 === 0 && <rect x={x - half * u + u * 0.6} y={y} width={half * 2 * u - u * 1.6} height={u * 0.4} fill="#d4dcf3" opacity={0.55} />}
</g>
)
})}
</g>
)
}
/** Stepped (voxel) ridge: one block column every `step` units. */
function ridge(from: number, to: number, step: number, base: number, heights: number[]) {
let d = `M${from} ${HORIZON + 6}`
for (let x = from, i = 0; x < to; x += step, i++) d += ` V${base - heights[i % heights.length]} H${Math.min(x + step, to)}`
return `${d} V${HORIZON + 6} Z`
}
const TREES = [
{ x: 14, base: 142, h: 62 }, { x: 44, base: 140, h: 48 }, { x: 70, base: 144, h: 36 }, { x: 100, base: 147, h: 22 },
{ x: 268, base: 148, h: 14 }, { x: 281, base: 148, h: 10 },
{ x: 440, base: 147, h: 22 }, { x: 488, base: 141, h: 44 }, { x: 516, base: 139, h: 58 }, { x: 548, base: 142, h: 50 },
]
export const Backdrop = memo(function Backdrop() {
return (
<svg className="lv-gui-backdrop" viewBox={`0 0 ${W} 324`} preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<defs>
<linearGradient id="lvgb-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#020309" />
<stop offset="0.55" stopColor="#060b1b" />
<stop offset="1" stopColor="#141d3d" />
</linearGradient>
<linearGradient id="lvgb-lake" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#121a36" />
<stop offset="0.3" stopColor="#262f5a" />
<stop offset="0.7" stopColor="#434c84" />
<stop offset="1" stopColor="#5d67a3" />
</linearGradient>
<linearGradient id="lvgb-mist" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#7d8ad0" stopOpacity="0" />
<stop offset="0.5" stopColor="#7d8ad0" stopOpacity="0.22" />
<stop offset="1" stopColor="#7d8ad0" stopOpacity="0" />
</linearGradient>
<radialGradient id="lvgb-moon" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stopColor="#dfe6ff" stopOpacity="0.28" />
<stop offset="1" stopColor="#dfe6ff" stopOpacity="0" />
</radialGradient>
<linearGradient id="lvgb-glint" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#c9d3ff" stopOpacity="0.16" />
<stop offset="1" stopColor="#c9d3ff" stopOpacity="0" />
</linearGradient>
</defs>
<rect width={W} height={HORIZON + 6} fill="url(#lvgb-sky)" />
{STARS.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#fff" opacity={s.o} className={s.twinkle ? 'lv-gui-twinkle' : undefined} style={{ animationDelay: `${(i % 7) * 0.6}s` }} />
))}
<circle cx={452} cy={44} r={46} fill="url(#lvgb-moon)" />
<rect x={445} y={37} width={14} height={14} fill="#eef2ff" opacity={0.92} />
<rect x={448} y={40} width={4} height={3} fill="#c9d0ea" opacity={0.7} />
<path d={ridge(0, W, 14, HORIZON, [18, 24, 30, 26, 20, 14, 10, 8, 12, 16, 22, 28, 34, 30, 24, 18, 12, 10, 14, 20, 26, 30, 24, 20, 16, 22, 28, 34, 38, 32, 26, 20, 16, 12, 10, 14, 18, 24, 28, 22])} fill="#1a2349" />
<path d={ridge(0, W, 14, HORIZON, [18, 24, 30, 26, 20, 14, 10, 8, 12, 16, 22, 28, 34, 30, 24, 18, 12, 10, 14, 20, 26, 30, 24, 20, 16, 22, 28, 34, 38, 32, 26, 20, 16, 12, 10, 14, 18, 24, 28, 22])} fill="none" stroke="#9aa6d8" strokeWidth="0.6" opacity="0.35" />
<path d={ridge(0, 120, 8, HORIZON, [10, 12, 12, 9, 7, 5, 5, 4, 3, 3, 2, 2, 1, 1, 0])} fill="#0d1530" />
<path d={ridge(380, W, 8, HORIZON, [0, 1, 2, 3, 4, 6, 6, 8, 9, 10, 10, 12, 12, 11, 13, 12, 12, 11, 12, 13, 12, 12, 11])} fill="#0d1530" />
{TREES.map((t, i) => (
<Spruce key={i} {...t} />
))}
<rect y={HORIZON} width={W} height={324 - HORIZON} fill="url(#lvgb-lake)" />
<g stroke="#dfe6ff" strokeWidth="0.5">
{[6, 14, 25, 40, 60, 86, 118, 156].map((dy, i) => (
<line key={dy} x1="0" x2={W} y1={HORIZON + dy} y2={HORIZON + dy} opacity={0.02 + i * 0.008} />
))}
</g>
<rect x={430} y={HORIZON} width={44} height={120} fill="url(#lvgb-glint)" />
<rect y={HORIZON - 22} width={W} height={44} fill="url(#lvgb-mist)" />
<g fill="#fff">
{FLAKES.map((f, i) => (
<rect key={i} className="lv-gui-flake" x={f.x} y={f.y} width={f.s} height={f.s} opacity={0.55} style={{ animationDuration: `${f.d}s`, animationDelay: `${-i * 1.3}s` }} />
))}
</g>
</svg>
)
})

View file

@ -1,43 +1,88 @@
import type { ReactNode } from 'react'
import type { Cursor } from '../demo'
import type { CategoryId } from './modules'
const PATHS: Record<CategoryId, string> = {
combat: 'M14.5 3.5 20.5 3.5 20.5 9.5 10 20 7 20 4 17 4 14ZM6 13.5 10.5 18M3.5 20.5 6.5 17.5',
visuals: 'M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z',
player: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c.8-3.8 3.9-6 7.5-6s6.7 2.2 7.5 6',
misc: 'M5 5h5v5H5ZM14 5h5v5h-5ZM5 14h5v5H5ZM14 14h5v5h-5Z',
}
/** The Lucide icons the mod ships in `assets/lovisual/svg/`, drawn the same way (2px stroke). */
const ICONS = {
combat: (
<>
<polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5" />
<path d="M13 19 19 13M16 16l4 4M19 21l2-2" />
<polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5" />
<path d="M5 14l4 4M7 17l-3 3M3 19l2 2" />
</>
),
player: (
<>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</>
),
visuals: (
<>
<path d="m17 14 3 3.3a1 1 0 0 1-.7 1.7H4.7a1 1 0 0 1-.7-1.7L7 14h-.3a1 1 0 0 1-.7-1.7L9 9h-.2A1 1 0 0 1 8 7.3L12 3l4 4.3a1 1 0 0 1-.8 1.7H15l3 3.3a1 1 0 0 1-.7 1.7H17Z" />
<path d="M12 22v-3" />
</>
),
other: (
<>
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</>
),
check: <path d="M20 6 9 17l-5-5" />,
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-4.2-4.2" />
</>
),
themes: (
<>
<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" />
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
</>
),
layout: (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18M9 12h12M15 12v9" />
</>
),
account: (
<>
<path d="M11.5 15H7a4 4 0 0 0-4 4v2" />
<path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" />
<circle cx="10" cy="7" r="4" />
</>
),
configs: (
<>
<path d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.98a2 2 0 0 1 1.69.9l.66 1.2A2 2 0 0 0 12 6h8a2 2 0 0 1 2 2v3.3" />
<circle cx="18" cy="18" r="3" />
<path d="m14.3 19.5.9-.4M15.2 16.9l-.9-.4M16.9 15.2l-.4-.9M16.9 20.8l-.4.9M19.1 15.2l.4-.9M19.5 21.7l-.4-.9M20.8 16.9l.9-.4M20.8 19.1l.9.4" />
</>
),
} satisfies Record<string, ReactNode>
export function CategoryIcon({ id }: { id: CategoryId }) {
export type IconName = keyof typeof ICONS
export function Icon({ name, className = 'lv-gui-icon' }: { name: IconName; className?: string }) {
return (
<svg viewBox="0 0 24 24" className="lv-gui-icon" aria-hidden="true">
<path d={PATHS[id]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function Chevron({ open }: { open: boolean }) {
return (
<svg viewBox="0 0 16 16" className="lv-gui-chevron" data-open={open} aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function LogoMark() {
return (
<svg viewBox="0 0 20 20" className="lv-gui-logo-mark" aria-hidden="true">
<path d="M10 1.5 18.5 10 10 18.5 1.5 10Z" fill="var(--gui-accent)" />
<path d="M10 5.5 14.5 10 10 14.5 5.5 10Z" fill="var(--gui-window-bg)" />
</svg>
)
}
export function SearchGlyph() {
return (
<svg viewBox="0 0 16 16" className="lv-gui-icon" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<svg
viewBox="0 0 24 24"
className={className}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{ICONS[name]}
</svg>
)
}
@ -57,13 +102,7 @@ export function DemoCursor({ cursor }: { cursor: Cursor }) {
>
<span className="lv-gui-cursor-ring" />
<svg viewBox="0 0 20 24" width="20" height="24">
<path
d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z"
fill="#fff"
stroke="#0b1012"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<path d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z" fill="#fff" stroke="#0b1012" strokeWidth="1.3" strokeLinejoin="round" />
</svg>
</div>
)

View file

@ -1,92 +0,0 @@
import type { Dispatch } from 'react'
import type { GuiAction, GuiState } from '../demo'
import { type Setting, settingKey } from './modules'
const COLOR_CYCLE = ['var(--gui-accent)', '#ff6b9a', '#f2c14e', '#7cf29c', '#9d7bff']
type Props = { module: string; settings: Setting[]; state: GuiState; dispatch: Dispatch<GuiAction> }
/** Expanded settings of a module card: sliders, checkboxes and color dots like the in-game panel. */
export function SettingControls({ module, settings, state, dispatch }: Props) {
return (
<div className="lv-gui-settings">
{settings.map((s) => {
const key = settingKey(module, s.name)
const value = state.values[key] ?? s.value
if (s.kind === 'slider') {
return <Slider key={key} id={key} setting={s} value={Number(value)} dragging={state.dragging} dispatch={dispatch} />
}
if (s.kind === 'check') {
const on = Boolean(value)
return (
<button
key={key}
type="button"
role="checkbox"
aria-checked={on}
data-demo={`check:${key}`}
className="lv-gui-check-row"
onClick={() => dispatch({ type: 'set', key, value: !on })}
>
<span>{s.name}</span>
<span className="lv-gui-check" data-on={on}>
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.2 5 8.6l4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</span>
</button>
)
}
const current = String(value)
const next = COLOR_CYCLE[(COLOR_CYCLE.indexOf(current) + 1) % COLOR_CYCLE.length]
return (
<button
key={key}
type="button"
className="lv-gui-check-row"
aria-label={s.name}
onClick={() => dispatch({ type: 'set', key, value: next })}
>
<span>{s.name}</span>
<span className="lv-gui-color" style={{ background: current, boxShadow: `0 0 10px ${current}` }} />
</button>
)
})}
</div>
)
}
type SliderProps = {
id: string
setting: Extract<Setting, { kind: 'slider' }>
value: number
dragging: boolean
dispatch: Dispatch<GuiAction>
}
function Slider({ id, setting, value, dragging, dispatch }: SliderProps) {
const pct = ((value - setting.min) / (setting.max - setting.min)) * 100
return (
<label className="lv-gui-slider" data-dragging={dragging}>
<span className="lv-gui-slider-head">
<span>{setting.name}</span>
<span className="lv-gui-slider-value">
{Math.round(value)}
{setting.unit ?? ''}
</span>
</span>
<span className="lv-gui-slider-track" data-demo={`slider:${id}`}>
<span className="lv-gui-slider-fill" style={{ width: `${pct}%` }} />
<span className="lv-gui-slider-knob" data-demo={`knob:${id}`} style={{ left: `${pct}%` }} />
<input
type="range"
min={setting.min}
max={setting.max}
value={value}
aria-label={setting.name}
onChange={(e) => dispatch({ type: 'set', key: id, value: Number(e.target.value) })}
/>
</span>
</label>
)
}

View file

@ -1,197 +1,197 @@
/* ClickGui replica. Colors come from themeToCssVars(); the typed @property registrations
let a theme switch cross-fade (400ms) instead of snapping. */
/* ClickGui replica — the "screen": world backdrop, HUD watermark, top pill bar, search button,
hint lines and the demo cursor. Everything is sized in the mod's logical units: --u is one
logical pixel of the in-game layout (panel = 115×240u), derived from the container width.
Colors come from themeToCssVars(); typed @property registrations make a theme switch
cross-fade (400ms) instead of snapping. */
@property --gui-window-from { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-window-to { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-header-from { syntax: "<color>"; inherits: true; initial-value: #142226; }
@property --gui-header-to { syntax: "<color>"; inherits: true; initial-value: #142226; }
@property --gui-card-from { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
@property --gui-card-to { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
@property --gui-surface { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-surface-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-card-off { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
@property --gui-text { syntax: "<color>"; inherits: true; initial-value: #fff; }
@property --gui-text-muted { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
@property --gui-accent { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-accent-soft { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-accent-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
@property --gui-panel-tint { syntax: "<color>"; inherits: true; initial-value: #1d2224; }
@property --gui-panel-stroke { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-split { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-row-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-row-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gui-row-text { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
@property --gui-tab-left { syntax: "<color>"; inherits: true; initial-value: #151c1e; }
@property --gui-tab-right { syntax: "<color>"; inherits: true; initial-value: #151c1e; }
@property --gui-tab-active-left { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-tab-active-right { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
@property --gui-menu-left { syntax: "<color>"; inherits: true; initial-value: #12191b; }
@property --gui-menu-right { syntax: "<color>"; inherits: true; initial-value: #12191b; }
.lv-gui {
position: relative;
width: 100%;
max-width: 660px;
height: 452px;
container-type: inline-size;
font-family: var(--font-sans);
color: var(--gui-text);
font-size: 13px;
line-height: 1.3;
text-align: left;
user-select: none;
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-card-from,
--gui-card-to, --gui-stroke-from, --gui-stroke-to, --gui-surface, --gui-surface-hover, --gui-card-off, --gui-text,
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft;
transition-property: --gui-window-from, --gui-window-to, --gui-stroke-from, --gui-stroke-to, --gui-text, --gui-text-muted,
--gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-panel-tint, --gui-panel-stroke, --gui-split, --gui-row-hover,
--gui-row-glow, --gui-row-text, --gui-tab-left, --gui-tab-right, --gui-tab-active-left, --gui-tab-active-right,
--gui-menu-left, --gui-menu-right;
transition-duration: 400ms;
transition-timing-function: ease;
}
.lv-gui button { font: inherit; color: inherit; cursor: pointer; }
.lv-gui-icon { width: 16px; height: 16px; flex: none; }
.lv-gui-icon { width: 1em; height: 1em; flex: none; }
.lv-gui-bloom {
position: absolute;
inset: 8% 4% -2%;
border-radius: 40px;
background: radial-gradient(60% 60% at 50% 45%, var(--gui-accent-glow), transparent 70%);
filter: blur(40px);
opacity: 0.9;
pointer-events: none;
}
.lv-gui-window {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
border-radius: 12px;
border: 1px solid transparent;
/* Narrow (< 900px): two panels, a taller screen. Wide: all four panels. */
.lv-gui-stage {
--u: calc(100cqw / 264);
position: relative;
aspect-ratio: 264 / 330;
overflow: hidden;
background:
linear-gradient(var(--gui-window-angle), var(--gui-window-from), var(--gui-window-to)) padding-box,
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent) 55%, transparent), var(--gui-stroke-from) 35%,
var(--gui-stroke-to) 70%, color-mix(in srgb, var(--gui-accent) 30%, transparent)) border-box;
box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.75), 0 0 0 1px rgb(0 0 0 / 0.35), 0 0 42px -6px var(--gui-accent-glow);
backdrop-filter: blur(14px) saturate(1.2);
border-radius: 14px;
font-family: "SF Pro Display", -apple-system, "Inter", var(--font-sans), sans-serif;
box-shadow: 0 30px 80px -24px rgb(0 0 0 / 0.8), 0 0 0 1px rgb(255 255 255 / 0.07), 0 0 60px -20px var(--gui-accent-glow);
}
@container (min-width: 900px) {
.lv-gui-stage { --u: calc(100cqw / 560); aspect-ratio: 560 / 324; }
}
.lv-gui-header {
/* The world: Backdrop.tsx draws a night lake in SVG; `--gui-backdrop-image` (e.g. a real F1
screenshot) paints over it when set. */
.lv-gui-world { position: absolute; inset: 0; background: #020309; }
.lv-gui-backdrop { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.lv-gui-photo { position: absolute; inset: 0; background: var(--gui-backdrop-image, none) center / cover no-repeat; }
.lv-gui-twinkle { animation: lv-gui-twinkle 3.2s ease-in-out infinite alternate; }
.lv-gui-flake { animation: lv-gui-snow 12s linear infinite; }
@keyframes lv-gui-twinkle { to { opacity: 0.15; } }
@keyframes lv-gui-snow {
0% { transform: translate(0, -20px); opacity: 0; }
15%, 80% { opacity: 0.55; }
100% { transform: translate(-14px, 70px); opacity: 0; }
}
.lv-gui-watermark {
position: absolute;
z-index: 2;
left: calc(var(--u) * 3);
top: calc(var(--u) * 3);
margin: 0;
padding: calc(var(--u) * 1) calc(var(--u) * 2.2);
border-radius: calc(var(--u) * 1.6);
background: rgb(22 22 26 / 0.82);
font-size: calc(var(--u) * 5.4);
font-weight: 500;
white-space: nowrap;
}
.lv-gui-watermark b { color: var(--gui-accent); font-weight: 600; }
.lv-gui-watermark span { margin-left: calc(var(--u) * 1.4); }
/* ClickGuiTabBar: clock pill + a two-segment switch whose accent thumb slides between tabs. */
.lv-gui-bar {
position: absolute;
z-index: 3;
top: calc(var(--u) * 4);
left: 50%;
display: flex;
align-items: center;
gap: 14px;
height: 44px;
padding: 0 14px;
flex: none;
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 25%, transparent);
gap: calc(var(--u) * 2.2);
transform: translateX(-50%);
}
.lv-gui-logo { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); font-weight: 700; font-size: 13px; letter-spacing: 0.02em; }
.lv-gui-logo-mark { width: 16px; height: 16px; filter: drop-shadow(0 0 6px var(--gui-accent)); }
.lv-gui-tabs { display: none; gap: 4px; margin-left: 10px; }
.lv-gui-tab { position: relative; padding: 5px 10px; border-radius: 7px; color: var(--gui-text-muted); font-size: 12px; }
.lv-gui-tab[data-active="true"] { color: var(--gui-text); background: color-mix(in srgb, var(--gui-accent) 14%, transparent); }
.lv-gui-tab[data-active="true"]::after {
content: ""; position: absolute; left: 10px; right: 10px; bottom: -8px; height: 2px; border-radius: 2px;
background: var(--gui-accent); box-shadow: 0 0 10px var(--gui-accent);
.lv-gui-clock {
padding: calc(var(--u) * 1.3) calc(var(--u) * 3.4);
border-radius: 999px;
background: rgb(10 10 12 / 0.88);
border: 1px solid rgb(255 255 255 / 0.12);
font-size: calc(var(--u) * 4.4);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.lv-gui-tabs {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
height: calc(var(--u) * 12);
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 calc(var(--u) * 2) calc(var(--u) * 7) rgb(0 0 0 / 0.35);
}
.lv-gui-tab {
position: relative;
min-width: calc(var(--u) * 33);
padding: 0 calc(var(--u) * 5);
border: 0;
background: none;
color: var(--gui-text-muted) !important;
font-size: calc(var(--u) * 5) !important;
font-weight: 600 !important;
transition: color 250ms;
}
.lv-gui-tab:hover { color: color-mix(in srgb, var(--gui-text-muted), var(--gui-text) 55%) !important; }
.lv-gui-tab[aria-selected="true"] { color: var(--gui-text) !important; }
.lv-gui-tab-thumb {
position: absolute;
top: calc(var(--u) * 1.2);
bottom: calc(var(--u) * 1.2);
left: calc(var(--u) * 1.2);
width: calc(50% - var(--u) * 2.4);
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 calc(var(--u) * 5) color-mix(in srgb, var(--gui-accent-soft) 45%, transparent);
transition: left 380ms cubic-bezier(0.22, 1, 0.36, 1);
}
.lv-gui-stage[data-tab="settings"] .lv-gui-tab-thumb { left: calc(50% + var(--u) * 1.2); }
.lv-gui-search {
margin-left: auto; display: flex; align-items: center; justify-content: center; width: 30px; height: 26px;
border-radius: 7px; color: var(--gui-text-muted); background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
position: absolute;
z-index: 2;
top: calc(var(--u) * 26);
right: calc(var(--u) * 10);
display: grid;
place-items: center;
width: calc(var(--u) * 20);
height: calc(var(--u) * 20);
border-radius: calc(var(--u) * 6);
border: 1px solid rgb(255 255 255 / 0.16);
background: rgb(8 8 10 / 0.42);
backdrop-filter: blur(calc(var(--u) * 4));
transition: background-color 200ms;
}
.lv-gui-search:hover { background: rgb(40 40 46 / 0.55); }
.lv-gui-search-icon { width: calc(var(--u) * 9); height: calc(var(--u) * 9); color: var(--gui-text); }
.lv-gui-body { display: flex; min-height: 0; flex: 1; }
.lv-gui-cats {
display: flex; flex-direction: column; gap: 3px; width: 46px; flex: none; padding: 10px 6px;
border-right: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 14%, transparent);
background: color-mix(in srgb, black 12%, transparent);
.lv-gui-screen {
position: absolute;
inset: calc(var(--u) * 50) 0 auto;
height: calc(var(--u) * 240);
animation: lv-gui-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.lv-gui-cat {
position: relative; display: flex; align-items: center; justify-content: center; gap: 9px; height: 32px;
padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--gui-text-muted) !important;
transition: background-color 200ms, color 200ms;
}
.lv-gui-cat:hover { background: var(--gui-surface-hover); }
.lv-gui-cat[data-active="true"] {
color: var(--gui-text) !important;
background: linear-gradient(90deg, color-mix(in srgb, var(--gui-accent) 24%, transparent), transparent);
}
.lv-gui-cat[data-active="true"]::before {
content: ""; position: absolute; left: -6px; top: 7px; bottom: 7px; width: 3px; border-radius: 0 3px 3px 0;
background: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent);
}
.lv-gui-cat[data-active="true"] .lv-gui-icon { color: var(--gui-accent); filter: drop-shadow(0 0 5px var(--gui-accent)); }
.lv-gui-cat-name, .lv-gui-cat-count { display: none; }
.lv-gui-cat-count { margin-left: auto; font-family: var(--font-code); font-size: 10px; opacity: 0.6; }
.lv-gui-theme-dots { margin-top: auto; display: grid; grid-template-columns: 1fr; justify-items: center; gap: 7px; padding-top: 10px; }
.lv-gui-theme-dot {
width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgb(255 255 255 / 0.25); background: var(--dot);
box-shadow: inset 0 0 0 3px rgb(0 0 0 / 0.25), 0 0 0 0 var(--dot-accent); transition: box-shadow 250ms, transform 250ms;
}
.lv-gui-theme-dot::after { content: ""; display: block; margin: 4px; height: 6px; border-radius: 50%; background: var(--dot-accent); }
.lv-gui-theme-dot[data-active="true"] { box-shadow: 0 0 0 2px var(--dot-accent), 0 0 12px var(--dot-accent); transform: scale(1.1); }
@keyframes lv-gui-in { from { opacity: 0; transform: translateY(calc(var(--u) * 8)) scale(0.97); } }
.lv-gui-main {
position: relative; flex: 1; min-width: 0; overflow: hidden; padding: 10px;
mask-image: linear-gradient(to bottom, #000 82%, transparent);
.lv-gui-hints {
position: absolute;
left: calc(var(--u) * 8);
right: calc(var(--u) * 8);
bottom: calc(var(--u) * 5);
margin: 0;
padding: 0;
list-style: none;
columns: 2;
column-gap: calc(var(--u) * 8);
font-size: calc(var(--u) * 5.6);
line-height: 1.4;
color: rgb(255 255 255 / 0.78);
text-shadow: 0 1px 2px rgb(0 0 0 / 0.6);
white-space: nowrap;
}
.lv-gui-grid { display: grid; grid-template-columns: 1fr; gap: 8px; align-items: start; animation: lv-gui-in 380ms ease both; }
@keyframes lv-gui-in { from { opacity: 0; transform: translateY(8px); } }
.lv-gui-card {
position: relative; overflow: hidden; padding: 8px 9px 7px; border-radius: 6px;
background: linear-gradient(180deg, color-mix(in srgb, var(--gui-surface-hover) 70%, transparent), var(--gui-card-off)),
rgb(8 12 14 / 0.55);
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent);
box-shadow: 0 2px 9px rgb(0 0 0 / 0.28);
transition: border-color 250ms, box-shadow 250ms, transform 200ms;
}
.lv-gui-card:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--gui-stroke-soft) 26%, transparent); }
.lv-gui-card[data-on="true"] {
background: linear-gradient(var(--gui-card-angle, 180deg), var(--gui-card-from), var(--gui-card-to)), rgb(8 12 14 / 0.45);
border-color: color-mix(in srgb, var(--gui-accent) 40%, transparent);
box-shadow: 0 2px 14px rgb(0 0 0 / 0.3), 0 0 18px -6px var(--gui-accent-glow);
}
.lv-gui-card-glow {
position: absolute; left: -30px; top: -40px; width: 120px; height: 90px; border-radius: 50%;
background: radial-gradient(closest-side, var(--gui-accent-soft), transparent); opacity: 0.12; pointer-events: none;
transition: opacity 250ms;
}
.lv-gui-card[data-on="true"] .lv-gui-card-glow { opacity: 0.4; }
.lv-gui-card-title { position: relative; margin: 0; font-family: var(--font-sans); font-weight: 600; font-size: 13px; color: var(--gui-text); opacity: 0.66; transition: opacity 250ms; }
.lv-gui-card[data-on="true"] .lv-gui-card-title { opacity: 1; }
.lv-gui-card-desc { position: relative; margin: 2px 0 0; font-size: 11px; color: var(--gui-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lv-gui-card-divider { height: 1px; margin: 7px -9px 6px; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent), transparent); }
.lv-gui-card-foot { position: relative; display: flex; align-items: center; gap: 6px; }
.lv-gui-bind { font-size: 10px; color: var(--gui-text-muted); }
.lv-gui-bind span { font-family: var(--font-code); }
.lv-gui-nosettings { display: none; }
.lv-gui-expand {
margin-left: auto; display: flex; width: 22px; height: 18px; align-items: center; justify-content: center;
border: 0; border-radius: 5px; background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
}
.lv-gui-expand:hover { background: var(--gui-surface-hover); }
.lv-gui-nosettings + .lv-gui-toggle, .lv-gui-bind + .lv-gui-toggle { margin-left: auto; }
.lv-gui-chevron { width: 12px; height: 12px; color: var(--gui-text-muted); transition: transform 250ms; }
.lv-gui-chevron[data-open="true"] { transform: rotate(180deg); color: var(--gui-accent); }
.lv-gui-toggle {
position: relative; width: 28px; height: 15px; flex: none; padding: 0; border-radius: 99px;
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent); background: rgb(0 0 0 / 0.35);
transition: background-color 250ms, box-shadow 250ms, border-color 250ms;
}
.lv-gui-toggle-knob {
position: absolute; top: 2px; left: 2px; width: 9px; height: 9px; border-radius: 50%;
background: var(--gui-text-muted); transition: transform 250ms cubic-bezier(0.3, 1.4, 0.5, 1), background-color 250ms;
}
.lv-gui-toggle[aria-checked="true"] {
background: var(--gui-accent); border-color: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent-glow), 0 0 4px var(--gui-accent);
}
.lv-gui-toggle[aria-checked="true"] .lv-gui-toggle-knob { transform: translateX(13px); background: #fff; }
.lv-gui-settings { position: relative; display: flex; flex-direction: column; gap: 7px; margin-top: 8px; padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent); animation: lv-gui-in 260ms ease both; }
.lv-gui-slider { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--gui-text-muted); }
.lv-gui-slider-head { display: flex; justify-content: space-between; }
.lv-gui-slider-value { font-family: var(--font-code); color: var(--gui-text); }
.lv-gui-slider-track { position: relative; display: block; height: 4px; border-radius: 4px; background: rgb(0 0 0 / 0.4); }
.lv-gui-slider-fill { position: absolute; inset: 0 auto 0 0; border-radius: 4px; background: linear-gradient(90deg, var(--gui-accent-soft), var(--gui-accent)); box-shadow: 0 0 8px var(--gui-accent-glow); }
.lv-gui-slider-knob { position: absolute; top: -4px; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 45%, transparent), 0 0 10px var(--gui-accent); }
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-fill { transition: width 900ms cubic-bezier(0.4, 0, 0.3, 1); }
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-knob { transition: left 900ms cubic-bezier(0.4, 0, 0.3, 1); }
.lv-gui-slider input { position: absolute; inset: -8px 0; width: 100%; opacity: 0; cursor: pointer; margin: 0; }
.lv-gui-check-row { display: flex; align-items: center; justify-content: space-between; padding: 0; border: 0; background: none; font-size: 11px; color: var(--gui-text-muted) !important; }
.lv-gui-check { display: flex; width: 14px; height: 14px; border-radius: 4px; border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 40%, transparent); color: transparent; transition: all 200ms; }
.lv-gui-check[data-on="true"] { background: var(--gui-accent); border-color: var(--gui-accent); color: #0b1012; box-shadow: 0 0 8px var(--gui-accent-glow); }
.lv-gui-color { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgb(255 255 255 / 0.8); }
.lv-gui-hints li { overflow: hidden; text-overflow: ellipsis; }
.lv-gui-cursor {
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; opacity: 0;
@ -207,14 +207,7 @@
}
.lv-gui-cursor[data-pressed="true"] .lv-gui-cursor-ring { opacity: 0.9; transform: scale(1); box-shadow: 0 0 14px var(--gui-accent); }
@container (min-width: 440px) {
.lv-gui-cats { width: 128px; }
.lv-gui-cat { justify-content: flex-start; }
.lv-gui-cat-name, .lv-gui-cat-count { display: inline; }
.lv-gui-theme-dots { grid-template-columns: repeat(4, 1fr); }
.lv-gui-grid { grid-template-columns: 1fr 1fr; }
.lv-gui-nosettings { display: inline; margin-left: auto; opacity: 0.6; }
}
@container (min-width: 560px) {
.lv-gui-tabs { display: flex; }
@media (prefers-reduced-motion: reduce) {
.lv-gui-screen, .lv-gui-twinkle, .lv-gui-flake { animation: none; }
.lv-gui-tab-thumb { transition: none; }
}

View file

@ -1,97 +1,67 @@
// In-game catalogue shown by the ClickGui replica. Module, category and setting names are
// the mod's own identifiers and stay untranslated; descriptions live in the clickgui i18n.
// In-game catalogue shown by the ClickGui replica: the mod's `@ModuleInfo` display names grouped
// the way `ModulesMenuCategory` groups them (MISC is shown as "Other"), sorted like the in-game
// list. Names are the mod's own labels and stay untranslated.
export type Setting =
| { kind: 'slider'; name: string; min: number; max: number; value: number; unit?: string }
| { kind: 'check'; name: string; value: boolean }
| { kind: 'color'; name: string; value: string }
export type CategoryId = 'combat' | 'player' | 'visuals' | 'other'
export type GuiModule = { name: ModuleName; on?: boolean; settings?: Setting[] }
export type CategoryId = 'combat' | 'visuals' | 'player' | 'misc'
export type GuiModule = { name: string; on?: boolean }
export type Category = { id: CategoryId; name: string; modules: GuiModule[] }
export type ModuleName =
| 'PvpCooldowns' | 'Predictions' | 'TargetESP' | 'DamageTint' | 'NoFriendDamage' | 'CombatLog' | 'TPSSync'
| 'ProjectilePuncher' | 'Trails' | 'JumpCircles' | 'HitBubbles' | 'AmbientParticles' | 'Wings' | 'ChinaHat'
| 'ShaderSky' | 'MotionBlur' | 'PostFX' | 'KillEffect' | 'ThirdPerson' | 'AutoTool' | 'DeathCoords'
| 'AutoRespawn' | 'ClickPearl' | 'LockSlot' | 'UseTracker' | 'NoDelay' | 'StreamerMode' | 'NameProtect'
| 'HitSounds' | 'ItemScroller' | 'MessageFilter' | 'Optimize' | 'NoServerPack' | 'BetterMinecraft'
const slider = (name: string, value: number, max = 100, unit?: string): Setting => ({
kind: 'slider', name, min: 0, max, value, unit,
})
const check = (name: string, value: boolean): Setting => ({ kind: 'check', name, value })
const color = (name: string, value: string): Setting => ({ kind: 'color', name, value })
const list = (names: string[], on: string[] = []): GuiModule[] =>
[...names]
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase(), 'en'))
.map((name) => (on.includes(name) ? { name, on: true } : { name }))
export const CATEGORIES: Category[] = [
{
id: 'combat',
name: 'Combat',
modules: [
{ name: 'TargetESP', on: true, settings: [slider('Size', 60), check('Pulse', true), color('Color', 'var(--gui-accent)')] },
{ name: 'PvpCooldowns', on: true },
{ name: 'Predictions', settings: [slider('Ticks', 6, 20), check('Show path', true)] },
{ name: 'DamageTint', on: true, settings: [slider('Strength', 40), color('Tint', '#ff5a6e')] },
{ name: 'NoFriendDamage' },
{ name: 'TPSSync' },
{ name: 'ProjectilePuncher' },
{ name: 'CombatLog' },
],
},
{
id: 'visuals',
name: 'Visuals',
modules: [
{ name: 'Trails', settings: [slider('Length', 45), check('Rainbow', false), color('Color', 'var(--gui-accent)')] },
{ name: 'JumpCircles', on: true, settings: [slider('Radius', 70), color('Color', 'var(--gui-accent)')] },
{ name: 'HitBubbles', on: true },
{ name: 'AmbientParticles', on: true, settings: [slider('Count', 120, 150), check('Glow', true)] },
{ name: 'Wings' },
{ name: 'ChinaHat', settings: [check('Gradient', true), color('Color', '#f2c14e')] },
{ name: 'ShaderSky', on: true },
{ name: 'MotionBlur', settings: [slider('Strength', 30)] },
{ name: 'PostFX', on: true, settings: [check('Bloom', true), slider('Vignette', 25)] },
{ name: 'KillEffect' },
],
modules: list(
['CombatLog', 'DamageTint', 'No Friend Damage', 'Predictions', 'ProjectilePuncher', 'RubberHand', 'SpearAssist', 'TargetESP', 'TPSSync'],
['No Friend Damage', 'Predictions', 'TargetESP'],
),
},
{
id: 'player',
name: 'Player',
modules: [
{ name: 'ThirdPerson', on: true, settings: [slider('Distance', 4, 12, 'm')] },
{ name: 'AutoTool', on: true },
{ name: 'DeathCoords', on: true },
{ name: 'AutoRespawn' },
{ name: 'ClickPearl' },
{ name: 'LockSlot' },
{ name: 'UseTracker' },
{ name: 'NoDelay' },
],
modules: list(
['Auto Accept', 'Auto Leave', 'Auto Respawn', 'AutoTool', 'ClickPearl', 'Death Coords', 'FakeLag', 'Lock Slot', 'NoDelay',
'NoInteract', 'Tape Mouse', 'Third Person', 'Use Tracker'],
['Auto Accept'],
),
},
{
id: 'misc',
name: 'Misc',
modules: [
{ name: 'StreamerMode', settings: [check('Hide coords', true), check('Hide IP', true)] },
{ name: 'NameProtect', on: true },
{ name: 'HitSounds', on: true, settings: [slider('Volume', 65)] },
{ name: 'ItemScroller' },
{ name: 'MessageFilter' },
{ name: 'Optimize', on: true },
{ name: 'NoServerPack' },
{ name: 'BetterMinecraft' },
],
id: 'visuals',
name: 'Visuals',
modules: list(
['AlwaysVisibleHand', 'AmbientParticles', 'Animations', 'AspectRatio', 'AtmoDawnFog', 'BackSword', 'BlockESP', 'BlockHighlight',
'CameraClip', 'Chams', 'ChinaHat', 'Crosshair', 'CrystalHoles', 'DropESP', 'EndCrystal', 'ESP', 'FovControl', 'FPS Optimizer',
'Freecam', 'FullBright', 'Gps', 'Hitboxes', 'HitBubbles', 'HitEffect', 'InvisESP', 'Item Holder', 'Item Physic', 'JumpCircles',
'KillEffect', 'KineticLyrics', 'Light', 'Lightning Target ESP', 'MotionBlur', 'NameTags', 'NoRender', 'PostFX', 'Rain',
'ReimaginedVisual', 'ShaderSky', 'Shulker Preview', 'SoundESP', 'TazikHat', 'TotemFX', 'Trails', 'Trinket', 'Trollface Mask',
'ViewModel', 'Wet World', 'Wings', 'WorldParticles', 'WorldTweaks', 'Zoom'],
['Chams'],
),
},
{
id: 'other',
name: 'Other',
modules: list(
['Balancer', 'BetterMinecraft', 'ClickGUI', 'DefineTarget', 'FakePlayer', 'HitSounds', 'Item Scroller', 'MessageFilter',
'Name Protect', 'No Server Pack', 'NoSound', 'Optimize', 'PvpCooldowns', 'ShitESP', 'Streamer Mode'],
['ClickGUI'],
),
},
]
/** The ClickGui's top tab row — in-game labels. */
export const TABS = ['Modules', 'Themes', 'Configs', 'HUD'] as const
/** The pill bar's two segments — in-game labels. */
export const TABS = ['modules', 'settings'] as const
export type TabId = (typeof TABS)[number]
/** Presets offered as quick dots in the category column (and cycled by the demo). */
export const QUICK_THEMES = ['classic', 'nitro', 'sunset', 'aurora'] as const
/** Presets the scripted demo cycles through (all sit in the first rows of the theme grid). */
export const QUICK_THEMES = ['amber', 'purple', 'blue', 'classic'] as const
export const settingKey = (module: string, setting: string) => `${module}.${setting}`
export const findModule = (name: string) => CATEGORIES.flatMap((c) => c.modules).find((m) => m.name === name)
/** The settings sidebar, top to bottom; `null` draws a divider. Only "themes" has a page. */
export const SIDEBAR = ['hud', 'ui', 'themes', null, 'layout', 'account', null, 'configs'] as const
export type SidebarId = Exclude<(typeof SIDEBAR)[number], null>

View file

@ -0,0 +1,56 @@
import type { Dispatch } from 'react'
import { useTranslation } from 'react-i18next'
import type { GuiAction, GuiState } from '../demo'
import { Icon } from '../parts/Glyphs'
import { type Category, CATEGORIES } from '../parts/modules'
type Props = { state: GuiState; dispatch: Dispatch<GuiAction> }
/** `ModulesMenuScreen`: the four liquid-glass category panels, side by side. */
export function ModulesScreen({ state, dispatch }: Props) {
const { t } = useTranslation('clickgui')
return (
<div className="lv-gui-panels" role="list" aria-label={t('categories')}>
{CATEGORIES.map((c) => (
<Panel key={c.id} category={c} state={state} dispatch={dispatch} />
))}
</div>
)
}
function Panel({ category, state, dispatch }: { category: Category } & Props) {
const { t } = useTranslation('clickgui')
const titleId = `lv-gui-panel-${category.id}`
return (
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
<header className="lv-gui-panel-head">
<h3 id={titleId} className="lv-gui-panel-title">
{category.name}
</h3>
<Icon name={category.id} className="lv-gui-panel-icon" />
</header>
<ul className="lv-gui-rows">
{category.modules.map((m) => {
const on = Boolean(state.enabled[m.name])
return (
<li key={m.name}>
<button
type="button"
className="lv-gui-row"
data-on={on}
data-demo={`module:${m.name}`}
aria-pressed={on}
title={t('module', { name: m.name })}
onClick={() => dispatch({ type: 'toggle', module: m.name })}
onContextMenu={(e) => e.preventDefault()}
>
<span className="lv-gui-row-name">{m.name}</span>
{on && <Icon name="check" className="lv-gui-row-check" />}
</button>
</li>
)
})}
</ul>
</section>
)
}

View file

@ -0,0 +1,103 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { PRESETS } from '../../themes/presets.generated'
import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '../../themes/types'
import { Icon } from '../parts/Glyphs'
import { SIDEBAR } from '../parts/modules'
type Props = { activeId: string; onPick: (id: string) => void }
/** `MenuScreen` on its "Темы" page: icon sidebar + a two-column grid of theme cards. */
export function SettingsScreen({ activeId, onPick }: Props) {
const { t } = useTranslation('clickgui')
return (
<div className="lv-gui-menu">
<nav className="lv-gui-side" aria-label={t('settingsNav')}>
{SIDEBAR.map((id, i) =>
id === null ? (
<span key={`split-${i}`} className="lv-gui-side-split" />
) : (
<span
key={id}
className="lv-gui-side-item"
data-active={id === 'themes'}
aria-current={id === 'themes' ? 'page' : undefined}
aria-label={t(`sidebar.${id}`)}
role="img"
>
{id === 'hud' || id === 'ui' ? t(`sidebar.${id}`) : <Icon name={id} className="lv-gui-side-icon" />}
</span>
),
)}
</nav>
<div className="lv-gui-menu-body">
<header className="lv-gui-menu-head">
<Icon name="themes" className="lv-gui-menu-head-icon" />
<h3>{t('themes')}</h3>
<span className="lv-gui-menu-search" aria-hidden="true">
{t('search')}
<i />
<Icon name="search" className="lv-gui-menu-search-icon" />
</span>
</header>
<div className="lv-gui-themes" role="listbox" aria-label={t('themes')}>
{PRESETS.map((p) => (
<ThemeCard key={p.id} entry={p} selected={p.id === activeId} onPick={onPick} />
))}
</div>
</div>
</div>
)
}
/** `ThemeCardRenderer.fillSwatchPalette`: the 12 chips, each a start→end pair of palette slots. */
const CHIPS: [keyof ThemeColors, keyof ThemeColors][] = [
['windowBg', 'windowHeader'],
['cardEnabled', 'accentSoft'],
['windowHeader', 'windowStroke'],
['surface', 'surfaceHover'],
['surfaceHover', 'windowHeader'],
['accent', 'accentSoft'],
['accentSoft', 'strokeSoft'],
['windowStroke', 'strokeSoft'],
['strokeSoft', 'textMuted'],
['textPrimary', 'textMuted'],
['textMuted', 'surface'],
['windowBg', 'accentSoft'],
]
const chipGradient = (from: string, to: string, spec?: GradientSpec) => {
const [a, b] = spec?.enabled ? [spec.start, spec.end] : [from, to]
return `linear-gradient(135deg, ${argbToCss(a)}, ${argbToCss(b)})`
}
function ThemeCard({ entry, selected, onPick }: { entry: ThemeEntry; selected: boolean; onPick: (id: string) => void }) {
const { t } = useTranslation('clickgui')
const c = entry.theme
const style = {
'--card-accent': argbToCss(c.accent),
'--card-accent-soft': argbToCss(c.accentSoft),
'--card-bg': argbToCss(c.windowBg),
} as CSSProperties
return (
<button
type="button"
role="option"
aria-selected={selected}
aria-label={t('theme', { name: entry.name })}
className="lv-gui-theme"
data-selected={selected}
data-demo={`theme:${entry.id}`}
style={style}
onClick={() => onPick(entry.id)}
>
<span className="lv-gui-theme-name">{entry.name}</span>
<span className="lv-gui-theme-chips" aria-hidden="true">
{CHIPS.map(([from, to], i) => {
const spec = i === 0 ? entry.windowGradient : i === 1 ? entry.cardGradient : undefined
return <i key={i} style={{ background: chipGradient(c[from], c[to], spec) }} />
})}
</span>
</button>
)
}

View file

@ -0,0 +1,248 @@
/* The two ClickGui pages in the mod's logical units (--u, see parts/clickgui.css).
Modules: ModulesMenuScreen — PANEL_W 115, PANEL_H 240, gap 14, HEADER_H 24, SEPARATOR_H 4,
MODULE_ROW_H 20, radius 10, text inset 10. Settings: MenuScreen on the Темы page. */
.lv-gui-panels {
display: flex;
justify-content: center;
gap: calc(var(--u) * 14);
height: 100%;
}
.lv-gui-panel[data-category="player"], .lv-gui-panel[data-category="other"] { display: none; }
@container (min-width: 900px) {
.lv-gui-panel[data-category="player"], .lv-gui-panel[data-category="other"] { display: block; }
}
/* Liquid glass: near-opaque at the top, fading to a light veil so the world shows through. */
.lv-gui-panel {
position: relative;
flex: none;
width: calc(var(--u) * 115);
height: 100%;
overflow: hidden;
border-radius: calc(var(--u) * 10);
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) 92%, black) 0%,
color-mix(in srgb, var(--gui-panel-tint) 78%, transparent) 22%,
color-mix(in srgb, var(--gui-panel-tint) 46%, transparent) 55%,
color-mix(in srgb, var(--gui-panel-tint) 22%, transparent) 100%);
box-shadow: 0 calc(var(--u) * 5) calc(var(--u) * 18) rgb(0 0 0 / 0.28), inset 0 1px 0 rgb(255 255 255 / 0.1),
inset 0 -1px 0 rgb(255 255 255 / 0.06);
backdrop-filter: blur(calc(var(--u) * 5)) saturate(1.3);
}
.lv-gui-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
height: calc(var(--u) * 24);
padding: calc(var(--u) * 1) calc(var(--u) * 10) 0;
}
.lv-gui-panel-head::after {
content: "";
position: absolute;
left: calc(var(--u) * 8);
right: calc(var(--u) * 8);
top: calc(var(--u) * 24);
height: max(1px, calc(var(--u) * 0.5));
background: var(--gui-split);
}
.lv-gui-panel-title { margin: 0; font-family: inherit; font-size: calc(var(--u) * 9); font-weight: 600; letter-spacing: 0.005em; color: var(--gui-text); }
.lv-gui-panel-icon { width: calc(var(--u) * 8); height: calc(var(--u) * 8); color: color-mix(in srgb, var(--gui-row-text) 90%, transparent); }
.lv-gui-rows {
position: absolute;
inset: calc(var(--u) * 27.5) calc(var(--u) * 3) calc(var(--u) * 0.5) 0;
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--gui-text) 34%, transparent) color-mix(in srgb, var(--gui-text) 7%, transparent);
}
.lv-gui-rows::-webkit-scrollbar { width: calc(var(--u) * 2.5); }
.lv-gui-rows::-webkit-scrollbar-track { background: color-mix(in srgb, var(--gui-text) 7%, transparent); border-radius: 9px; }
.lv-gui-rows::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--gui-text) 34%, transparent); border-radius: 9px; }
.lv-gui-row {
position: relative;
display: flex;
align-items: center;
width: calc(var(--u) * 108);
height: calc(var(--u) * 20);
padding: 0 calc(var(--u) * 17) 0 calc(var(--u) * 10);
border: 0;
background: none;
font-size: calc(var(--u) * 8) !important;
font-weight: 400;
color: color-mix(in srgb, var(--gui-row-text), var(--gui-text) 25%) !important;
transition: color 250ms, padding 250ms;
}
.lv-gui-row::before {
content: "";
position: absolute;
inset: calc(var(--u) * 2) calc(var(--u) * 3);
border-radius: calc(var(--u) * 5);
background: radial-gradient(90% 140% at 50% 50%, var(--gui-row-glow), transparent 75%), var(--gui-row-hover);
opacity: 0;
transition: opacity 220ms;
}
.lv-gui-row::after {
content: "";
position: absolute;
left: calc(var(--u) * 3);
right: calc(var(--u) * 3);
bottom: 0;
height: 1px;
background: color-mix(in srgb, var(--gui-text) 5%, transparent);
}
.lv-gui-row:hover::before { opacity: 0.8; }
.lv-gui-row[data-on="true"] { padding-left: calc(var(--u) * 12); color: var(--gui-text) !important; }
.lv-gui-row[data-on="true"]::before { opacity: 0.45; }
.lv-gui-row[data-on="true"]:hover::before { opacity: 1; }
.lv-gui-row-name { position: relative; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.lv-gui-row-check {
position: absolute;
right: calc(var(--u) * 11);
top: calc(var(--u) * 7);
width: calc(var(--u) * 6);
height: calc(var(--u) * 6);
color: var(--gui-text);
stroke-width: 2.4;
animation: lv-gui-check 260ms ease both;
}
@keyframes lv-gui-check { from { opacity: 0; transform: translateX(calc(var(--u) * 2)) scale(0.6); } }
/* Settings → Темы: a centered window with an icon sidebar and the theme-card grid. */
.lv-gui-menu {
position: absolute;
left: 50%;
top: 50%;
display: grid;
grid-template-columns: calc(var(--u) * 30) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
width: calc(var(--u) * 250);
height: calc(var(--u) * 226);
transform: translate(-50%, -50%);
overflow: hidden;
border-radius: calc(var(--u) * 5);
border: 1px solid color-mix(in srgb, var(--gui-text) 13%, transparent);
background: linear-gradient(90deg, var(--gui-menu-left), var(--gui-menu-right));
box-shadow: 0 calc(var(--u) * 6) calc(var(--u) * 22) rgb(0 0 0 / 0.45);
backdrop-filter: blur(calc(var(--u) * 6)) saturate(1.2);
}
@container (min-width: 900px) {
.lv-gui-menu { width: calc(var(--u) * 336); height: calc(var(--u) * 196); }
}
.lv-gui-side {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: calc(var(--u) * 4);
border-right: 1px solid color-mix(in srgb, var(--gui-text) 9%, transparent);
background: rgb(0 0 0 / 0.12);
}
.lv-gui-side-item {
display: grid;
place-items: center;
width: calc(var(--u) * 15);
height: calc(var(--u) * 15);
border-radius: 50%;
font-size: calc(var(--u) * 4.4);
font-weight: 500;
color: var(--gui-text);
}
.lv-gui-side-item[data-active="true"] { background: color-mix(in srgb, var(--gui-text) 9%, transparent); }
.lv-gui-side-icon { width: calc(var(--u) * 6.4); height: calc(var(--u) * 6.4); }
.lv-gui-side-split { width: calc(var(--u) * 16); height: 1px; background: color-mix(in srgb, var(--gui-text) 12%, transparent); }
.lv-gui-menu-body { display: flex; flex-direction: column; min-width: 0; padding: calc(var(--u) * 5) calc(var(--u) * 4) 0 calc(var(--u) * 6); }
.lv-gui-menu-head { display: flex; align-items: center; gap: calc(var(--u) * 2.6); height: calc(var(--u) * 14); flex: none; }
.lv-gui-menu-head h3 { margin: 0; font-family: inherit; font-size: calc(var(--u) * 6.4); font-weight: 500; }
.lv-gui-menu-head-icon { width: calc(var(--u) * 6.2); height: calc(var(--u) * 6.2); }
.lv-gui-menu-search {
display: flex;
align-items: center;
gap: calc(var(--u) * 2.4);
margin-left: auto;
padding: calc(var(--u) * 1.4) calc(var(--u) * 3);
border-radius: calc(var(--u) * 2);
background: rgb(0 0 0 / 0.22);
font-size: calc(var(--u) * 5.6);
color: var(--gui-text-muted);
}
.lv-gui-menu-search i { width: 1px; height: calc(var(--u) * 5.4); background: color-mix(in srgb, var(--gui-text) 22%, transparent); }
.lv-gui-menu-search-icon { width: calc(var(--u) * 5); height: calc(var(--u) * 5); }
.lv-gui-themes {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: calc(var(--u) * 31);
gap: calc(var(--u) * 4);
flex: 1;
min-height: 0;
margin-top: calc(var(--u) * 2);
padding: calc(var(--u) * 1) calc(var(--u) * 5) calc(var(--u) * 5) calc(var(--u) * 0.5);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--gui-text) 30%, transparent) transparent;
}
.lv-gui-theme {
position: relative;
overflow: hidden;
padding: 0;
border: 0;
border-radius: calc(var(--u) * 2.2);
text-align: left;
background:
radial-gradient(120% 110% at 0% 0%, color-mix(in srgb, var(--card-accent) 16%, transparent), transparent 70%),
linear-gradient(180deg, color-mix(in srgb, var(--gui-panel-tint) 88%, white 4%), color-mix(in srgb, var(--card-bg) 70%, black));
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.05);
transition: box-shadow 300ms, transform 200ms;
}
.lv-gui-theme:hover { transform: translateY(-1px); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.14); }
.lv-gui-theme::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: calc(var(--u) * 10.5);
height: 1px;
background: linear-gradient(90deg, rgb(255 255 255 / 0.14), rgb(255 255 255 / 0.05));
}
.lv-gui-theme[data-selected="true"] {
background:
linear-gradient(160deg, color-mix(in srgb, var(--card-accent) 34%, transparent), color-mix(in srgb, var(--card-accent-soft) 12%, transparent)),
linear-gradient(180deg, color-mix(in srgb, var(--gui-panel-tint) 88%, white 4%), color-mix(in srgb, var(--card-bg) 70%, black));
box-shadow: inset 0 0 0 1px var(--card-accent), 0 0 calc(var(--u) * 7) color-mix(in srgb, var(--card-accent) 30%, transparent);
}
.lv-gui-theme[data-selected="true"]::before { background: linear-gradient(90deg, var(--card-accent), var(--card-accent-soft)); }
.lv-gui-theme-name {
position: absolute;
left: calc(var(--u) * 4);
top: calc(var(--u) * 2.4);
font-size: calc(var(--u) * 5.3);
font-weight: 500;
color: #fff;
}
.lv-gui-theme[data-selected="true"] .lv-gui-theme-name { color: color-mix(in srgb, #fff 76%, var(--card-accent)); }
.lv-gui-theme-chips {
position: absolute;
inset: calc(var(--u) * 11) calc(var(--u) * 4) 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.lv-gui-theme-chips i {
width: calc(var(--u) * 5.4);
height: calc(var(--u) * 5.4);
border-radius: calc(var(--u) * 1.3);
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 0.12);
transition: transform 200ms;
}
.lv-gui-theme-chips i:hover { transform: scale(1.18); }

View file

@ -1,47 +1,95 @@
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { PRESETS } from '../../themes/presets.generated'
import { argbToCss } from '../../themes/types'
import { ActiveThemeProvider } from '../../themes/useActiveTheme'
import { ClickGuiWindow } from '../ClickGuiWindow'
import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo'
const renderGui = () => render(<ActiveThemeProvider applyToPage={false}><ClickGuiWindow /></ActiveThemeProvider>)
const row = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}$`) })
test('reduced motion shows the finished demo frame with no cursor', () => {
test('the Modules screen shows the four in-game category panels', () => {
renderGui()
expect(screen.getByRole('button', { name: 'Visuals' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('slider', { name: 'Length' })).toHaveValue('85')
const panels = within(screen.getByRole('list', { name: 'Категории модулей' })).getAllByRole('listitem', { name: /./ })
expect(panels).toHaveLength(4)
for (const title of ['Combat', 'Player', 'Visuals', 'Other']) expect(screen.getByRole('heading', { name: title })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Modules' })).toHaveAttribute('aria-selected', 'true')
})
test('cards are interactive: switch categories, toggle and expand', async () => {
test('enabled modules carry a check mark; clicking a row toggles it', async () => {
const user = userEvent.setup()
renderGui()
await user.click(screen.getByRole('button', { name: 'Misc' }))
const toggle = screen.getByRole('switch', { name: 'Включить StreamerMode' })
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(screen.getByRole('button', { name: 'Настройки StreamerMode' }))
const card = toggle.closest('article') as HTMLElement
expect(within(card).getByRole('checkbox', { name: /Hide IP/ })).toHaveAttribute('aria-checked', 'true')
const predictions = row('Predictions')
expect(predictions).toHaveAttribute('aria-pressed', 'true')
expect(predictions.querySelector('.lv-gui-row-check')).not.toBeNull()
const autoLeave = row('Auto Leave')
expect(autoLeave.querySelector('.lv-gui-row-check')).toBeNull()
await user.click(autoLeave)
expect(autoLeave).toHaveAttribute('aria-pressed', 'true')
expect(autoLeave.querySelector('.lv-gui-row-check')).not.toBeNull()
})
test('the scripted demo toggles Trails and loops back to the start', () => {
test('reduced motion shows the finished demo frame', () => {
renderGui()
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'true')
expect(row('DamageTint')).toHaveAttribute('aria-pressed', 'true')
})
test('Settings lists every preset as a theme card, and picking one re-themes the GUI', async () => {
const user = userEvent.setup()
renderGui()
await user.click(screen.getByRole('tab', { name: 'Settings' }))
expect(screen.getByRole('heading', { name: 'Темы' })).toBeInTheDocument()
expect(screen.getAllByRole('option')).toHaveLength(PRESETS.length)
const nitro = PRESETS.find((p) => p.id === 'nitro')!
await user.click(screen.getByRole('option', { name: 'Тема Nitro' }))
expect(screen.getByRole('option', { name: 'Тема Nitro' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('clickgui-root').style.getPropertyValue('--gui-accent')).toBe(argbToCss(nitro.theme.accent))
await user.click(screen.getByRole('tab', { name: 'Modules' }))
expect(screen.getByRole('heading', { name: 'Combat' })).toBeInTheDocument()
})
test('the scripted demo toggles modules, visits Settings and loops back', () => {
vi.useFakeTimers()
vi.stubGlobal('matchMedia', (q: string) => ({ matches: !q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
renderGui()
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
act(() => vi.advanceTimersByTime(3200))
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
act(() => vi.advanceTimersByTime(LOOP_MS))
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'false')
act(() => vi.advanceTimersByTime(2000))
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'true')
act(() => vi.advanceTimersByTime(3000))
expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true')
act(() => vi.advanceTimersByTime(LOOP_MS + 600 - 5000 + 500)) // into the next cycle, before its first click
expect(screen.getByRole('tab', { name: 'Modules' })).toHaveAttribute('aria-selected', 'true')
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'false')
vi.unstubAllGlobals()
vi.useRealTimers()
})
test('demo timeline fits in the loop and the reducer resets', () => {
expect(Math.max(...DEMO.map((s) => s.at))).toBeLessThan(LOOP_MS)
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Trails' })
expect(toggled.enabled.Trails).toBe(true)
expect(guiReducer(toggled, { type: 'reset' }).enabled.Trails).toBe(false)
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Chams' })
expect(toggled.enabled.Chams).toBe(false)
const settings = guiReducer(toggled, { type: 'tab', id: 'settings' })
expect(guiReducer(settings, { type: 'reset' })).toEqual(initialGuiState())
})
test('controlled mode: onPickTheme receives the card and the site theme stays put; backdrop is swappable', async () => {
const user = userEvent.setup()
const onPickTheme = vi.fn()
render(
<ActiveThemeProvider applyToPage={false}>
<ClickGuiWindow demo={false} onPickTheme={onPickTheme} backdrop="/shots/f1.png" />
</ActiveThemeProvider>,
)
const root = screen.getByTestId('clickgui-root')
expect(root.style.getPropertyValue('--gui-backdrop-image')).toBe('url("/shots/f1.png")')
await user.click(screen.getByRole('tab', { name: 'Settings' }))
const before = screen.getByRole('option', { selected: true })
const accent = root.style.getPropertyValue('--gui-accent')
await user.click(screen.getByRole('option', { name: 'Тема Legacy' }))
expect(onPickTheme).toHaveBeenCalledWith('legacy')
expect(screen.getByRole('option', { selected: true })).toBe(before)
expect(root.style.getPropertyValue('--gui-accent')).toBe(accent)
})

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'
import { argbToCss, type GradientSpec, type ThemeEntry, withAlpha } from '../themes/types'
import { argbToCss, type GradientSpec, parseArgb, type ThemeEntry, withAlpha } from '../themes/types'
type CssVars = CSSProperties & Record<`--${string}`, string>
@ -46,6 +46,7 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
'--gui-stroke-soft': argbToCss(t.strokeSoft),
'--gui-accent-glow': withAlpha(t.accent, 0.45),
}
Object.assign(vars, derivedVars(entry))
for (const [slot, key, flat] of GRADIENT_SLOTS) {
const spec = entry[key]
vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat])
@ -55,3 +56,41 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
}
return vars
}
type Argb = { r: number; g: number; b: number; a: number }
/** `ClickGuiDrawHelpers.mixColor`: per-channel lerp of two packed ARGB colors, alpha included. */
function mix(from: Argb, to: Argb, t: number): Argb {
const l = (x: number, y: number) => Math.round(x * (1 - t) + y * t)
return { r: l(from.r, to.r), g: l(from.g, to.g), b: l(from.b, to.b), a: l(from.a, to.a) }
}
const opaque = (c: Argb): Argb => ({ ...c, a: 255 })
const css = (c: Argb, alpha = c.a) => `rgba(${c.r}, ${c.g}, ${c.b}, ${Number((alpha / 255).toFixed(3))})`
/**
* Colors the in-game renderers derive from the palette at draw time — `ModulesMenuStyle.syncTheme`
* (category panels) and `ClickGuiTabBar.drawTabs` (the Modules/Settings pill) — so the replica
* gets the very same tints instead of approximating them in CSS.
*/
function derivedVars(entry: ThemeEntry): CssVars {
const p = Object.fromEntries(Object.entries(entry.theme).map(([k, v]) => [k, parseArgb(v)])) as Record<
keyof ThemeEntry['theme'],
Argb
>
const accent = opaque(p.accent)
return {
'--gui-panel-tint': css(mix(opaque(p.surface), accent, 0.07)),
'--gui-panel-stroke': css(mix(opaque(p.strokeSoft), accent, 0.155), 76),
'--gui-split': css(mix(opaque(p.strokeSoft), accent, 0.12), 34),
'--gui-row-hover': css(mix(opaque(p.surfaceHover), accent, 0.125), 34),
'--gui-row-glow': css(mix(opaque(p.accentSoft), accent, 0.42), 92),
'--gui-row-text': css(opaque(p.textMuted), 210),
'--gui-tab-left': css(mix(p.surface, p.windowBg, 0.34)),
'--gui-tab-right': css(mix(p.surfaceHover, p.windowBg, 0.22)),
'--gui-tab-active-left': css(mix(p.accentSoft, p.accent, 0.18)),
'--gui-tab-active-right': css(mix(p.accent, p.textPrimary, 0.08)),
'--gui-menu-left': css(mix(p.windowBg, p.surface, 0.18), 215),
'--gui-menu-right': css(mix(p.windowBg, p.windowHeader, 0.3), 215),
}
}

View file

@ -0,0 +1,18 @@
import { api } from '../../shared/api/client'
import type { SlotSummary } from './types'
export function listSlots(): Promise<SlotSummary[]> {
return api.request('/configs')
}
export function regenerateCode(slot: number): Promise<{ share_code: string }> {
return api.request(`/configs/${slot}/regenerate-code`, { method: 'POST' })
}
export function publish(slot: number, title: string, description: string): Promise<{ listing_id: string }> {
return api.request(`/configs/${slot}/publish`, { method: 'POST', json: { title, description } })
}
export function unpublish(slot: number): Promise<void> {
return api.request(`/configs/${slot}/publish`, { method: 'DELETE' })
}

View file

@ -0,0 +1,73 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { TextField } from '../../../shared/ui/TextField'
import { publish } from '../api'
const MAX_DESCRIPTION = 500
export function PublishForm({
slot,
onCancel,
onPublished,
}: {
slot: number
onCancel: () => void
onPublished: () => void
}) {
const { t } = useTranslation('configs')
const client = useQueryClient()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [touched, setTouched] = useState(false)
const mutation = useMutation({
mutationFn: () => publish(slot, title.trim(), description),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['configs'] })
onPublished()
},
})
const invalid = touched && title.trim().length === 0
function submit() {
setTouched(true)
if (title.trim().length === 0 || title.trim().length > 64 || description.length > MAX_DESCRIPTION) return
mutation.mutate()
}
return (
<div className="flex flex-col gap-3 rounded-md border border-shoal bg-abyss/60 p-3">
<TextField
label={t('publishForm.title')}
value={title}
onChange={(e) => setTitle(e.target.value)}
error={invalid ? t('publishForm.titleRequired') : undefined}
maxLength={64}
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="publish-description" className="text-sm text-frost">
{t('publishForm.description')}
</label>
<textarea
id="publish-description"
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, MAX_DESCRIPTION))}
rows={3}
className="rounded-md border border-shoal bg-abyss px-3 py-2 text-snow focus:border-ice"
/>
<span className="self-end text-xs text-frost">{t('publishForm.counter', { count: description.length })}</span>
</div>
<div className="flex gap-2">
<Button variant="primary" busy={mutation.isPending} onClick={submit}>
{t('publishForm.submit')}
</Button>
<Button variant="quiet" onClick={onCancel}>
{t('publishForm.cancel')}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { listSlots } from '../api'
import type { SlotSummary } from '../types'
import { SlotPanel } from './SlotPanel'
const SLOTS = [1, 2, 3, 4] as const
export function SlotGrid() {
const { data } = useQuery({ queryKey: ['configs'], queryFn: listSlots })
const bySlot = new Map<number, SlotSummary>((data ?? []).map((s) => [s.slot, s]))
return (
<ul className="grid grid-cols-1 gap-4 min-[640px]:grid-cols-2">
{SLOTS.map((slot) => (
<li key={slot}>
<SlotPanel slot={slot} data={bySlot.get(slot) ?? null} />
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,124 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
import { ShareCode } from '../../../shared/ui/ShareCode'
import { regenerateCode, unpublish } from '../api'
import type { SlotSummary } from '../types'
import { PublishForm } from './PublishForm'
function formatDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
export function SlotPanel({ slot, data }: { slot: 1 | 2 | 3 | 4; data: SlotSummary | null }) {
const { t } = useTranslation('configs')
const client = useQueryClient()
const titleId = useId()
const [confirmingCode, setConfirmingCode] = useState(false)
const [confirmingUnpublish, setConfirmingUnpublish] = useState(false)
const [publishing, setPublishing] = useState(false)
const [notice, setNotice] = useState<string | null>(null)
const regen = useMutation({
mutationFn: () => regenerateCode(slot),
onSuccess: (res) => {
client.setQueryData<SlotSummary[]>(['configs'], (prev) =>
(prev ?? []).map((s) => (s.slot === slot ? { ...s, share_code: res.share_code } : s)),
)
setConfirmingCode(false)
},
})
const unpub = useMutation({
mutationFn: () => unpublish(slot),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['configs'] })
setConfirmingUnpublish(false)
setNotice(t('slot.unpublished'))
},
})
return (
<article
aria-labelledby={titleId}
className="flex flex-col gap-3 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-4 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]"
>
<div className="flex items-center gap-2">
<span aria-hidden="true" className="size-2 rounded-full bg-ice shadow-[0_0_6px_var(--color-ice)]" />
<h3 id={titleId} className="font-display text-lg text-snow">
{t('slot.heading', { slot })}
</h3>
</div>
{!data ? (
<div className="flex flex-col gap-1">
<p className="text-frost">{t('slot.empty')}</p>
<p className="font-code text-sm text-frost/80">{t('slot.emptyHint', { cmd: `%config save ${slot}` })}</p>
</div>
) : (
<div className="flex flex-col gap-3">
<p className="font-display text-xl text-snow">{data.name}</p>
<p className="text-sm text-frost">{t('slot.updated', { when: formatDate(data.updated_at) })}</p>
<ShareCode code={data.share_code} label={t('slot.codeLabel')} />
<p className="font-code text-xs text-frost/80">{t('slot.codeHint', { cmd: `%config load ${data.share_code}` })}</p>
{notice ? <Notice tone="info">{notice}</Notice> : null}
<div className="flex flex-wrap items-center gap-2">
{confirmingCode ? (
<>
<span className="text-sm text-ember">{t('slot.newCodeConfirm')}</span>
<Button variant="danger" busy={regen.isPending} onClick={() => regen.mutate()}>
{t('slot.change')}
</Button>
<Button variant="quiet" onClick={() => setConfirmingCode(false)}>
{t('slot.cancel')}
</Button>
</>
) : (
<Button variant="quiet" onClick={() => setConfirmingCode(true)}>
{t('slot.newCode')}
</Button>
)}
{!publishing &&
(data.published ? (
confirmingUnpublish ? (
<>
<span className="text-sm text-ember">{t('slot.unpublishConfirm')}</span>
<Button variant="danger" busy={unpub.isPending} onClick={() => unpub.mutate()}>
{t('slot.confirmUnpublish')}
</Button>
<Button variant="quiet" onClick={() => setConfirmingUnpublish(false)}>
{t('slot.cancel')}
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirmingUnpublish(true)}>
{t('slot.unpublish')}
</Button>
)
) : (
<Button variant="primary" onClick={() => setPublishing(true)}>
{t('slot.publish')}
</Button>
))}
</div>
{publishing ? (
<PublishForm
slot={slot}
onCancel={() => setPublishing(false)}
onPublished={() => {
setPublishing(false)
setNotice(t('slot.published'))
}}
/>
) : null}
</div>
)}
</article>
)
}

View file

@ -0,0 +1,33 @@
export const configsEn = {
page: {
title: 'My configs',
leadBefore: 'Configs are saved from the mod with',
leadAfter: '. This is where you get codes and publish.',
},
slot: {
heading: 'Slot {{slot}}',
empty: 'Empty',
emptyHint: 'Save a config here from the mod: {{cmd}}',
updated: 'Updated {{when}}',
codeLabel: 'Code for friends',
codeHint: 'A friend types this in-game: {{cmd}}',
newCode: 'New code',
newCodeConfirm: 'The old code will stop working. Change it?',
change: 'Change',
cancel: 'Cancel',
publish: 'Publish',
unpublish: 'Remove from showcase',
unpublishConfirm: 'The config will disappear from the showcase. Remove it?',
confirmUnpublish: 'Remove',
published: 'Published. The config is now visible in the showcase.',
unpublished: 'Removed from the showcase.',
},
publishForm: {
title: 'Showcase title',
description: 'Description',
counter: '{{count}}/500',
submit: 'Publish',
cancel: 'Cancel',
titleRequired: 'Title is required',
},
} as const

View file

@ -0,0 +1,33 @@
export const configsRu = {
page: {
title: 'Мои конфиги',
leadBefore: 'Конфиги сохраняются из мода командой',
leadAfter: '. Здесь коды и публикация.',
},
slot: {
heading: 'Слот {{slot}}',
empty: 'Пусто',
emptyHint: 'Сохрани сюда конфиг из мода: {{cmd}}',
updated: 'Обновлён {{when}}',
codeLabel: 'Код для друзей',
codeHint: 'Друг вводит в игре: {{cmd}}',
newCode: 'Новый код',
newCodeConfirm: 'Старый код перестанет работать. Сменить?',
change: 'Сменить',
cancel: 'Отмена',
publish: 'Опубликовать',
unpublish: 'Убрать с витрины',
unpublishConfirm: 'Конфиг исчезнет с витрины. Убрать?',
confirmUnpublish: 'Убрать',
published: 'Опубликовано. Конфиг виден на витрине.',
unpublished: 'Убрано с витрины.',
},
publishForm: {
title: 'Название на витрине',
description: 'Описание',
counter: '{{count}}/500',
submit: 'Опубликовать',
cancel: 'Отмена',
titleRequired: 'Название обязательно',
},
} as const

View file

@ -0,0 +1,64 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { SlotGrid } from '../components/SlotGrid'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
const filled = { slot: 2, name: 'pvp', updated_at: '2026-09-24T10:00:00Z', share_code: '7KQ3M9XA', published: false }
test('always shows four slots', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401), 'GET /configs': () => json([filled]) })
renderApp([{ path: '/', element: <SlotGrid /> }])
expect(await screen.findByText('7KQ3M9XA')).toBeInTheDocument()
expect(screen.getAllByRole('article')).toHaveLength(4)
expect(screen.getAllByText('Пусто')).toHaveLength(3)
})
test('regenerating a code needs confirmation', async () => {
const user = userEvent.setup()
let code = '7KQ3M9XA'
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /configs': () => json([{ ...filled, share_code: code }]),
'POST /configs/2/regenerate-code': () => {
code = 'NEWC0DE2'
return json({ share_code: code })
},
})
renderApp([{ path: '/', element: <SlotGrid /> }])
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
await user.click(within(panel).getByRole('button', { name: 'Новый код' }))
expect(within(panel).getByText(/Старый код перестанет работать/)).toBeInTheDocument()
await user.click(within(panel).getByRole('button', { name: 'Сменить' }))
expect(await within(panel).findByText('NEWC0DE2')).toBeInTheDocument()
})
test('publish requires a title and sends the form', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /configs': () => json([filled]),
'POST /configs/2/publish': () => json({ listing_id: 'l1' }),
})
renderApp([{ path: '/', element: <SlotGrid /> }])
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
expect(within(panel).getByLabelText('Название на витрине')).toHaveAttribute('aria-invalid', 'true')
await user.type(within(panel).getByLabelText('Название на витрине'), 'Лучший PvP')
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
expect(await within(panel).findByText(/Опубликовано/)).toBeInTheDocument()
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/publish'))?.[1]?.body
expect(JSON.parse(String(body))).toEqual({ title: 'Лучший PvP', description: '' })
})
test('published slot offers to remove from showcase', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /configs': () => json([{ ...filled, published: true }]),
})
renderApp([{ path: '/', element: <SlotGrid /> }])
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
expect(within(panel).getByRole('button', { name: 'Убрать с витрины' })).toBeInTheDocument()
})

View file

@ -0,0 +1,7 @@
export type SlotSummary = {
slot: 1 | 2 | 3 | 4
name: string
updated_at: string
share_code: string
published: boolean
}

View file

@ -0,0 +1,62 @@
import { useMutation } from '@tanstack/react-query'
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { describeError } from '../../shared/api/errors'
import { Button } from '../../shared/ui/Button'
import { Notice } from '../../shared/ui/Notice'
import { confirmDevice, normalizeUserCode } from './api'
/** The device-code confirmation form: styled like typing a command into the in-game chat bar. */
export function LinkDeviceForm() {
const { t } = useTranslation('deviceLink')
const { t: tc } = useTranslation('common')
const [code, setCode] = useState('')
const [error, setError] = useState<string>()
const mutation = useMutation({
mutationFn: (userCode: string) => confirmDevice(userCode),
onError: (err) => setError(describeError(tc, err, { 404: t('form.expired') })),
})
function submit(e: FormEvent) {
e.preventDefault()
setError(undefined)
mutation.mutate(normalizeUserCode(code))
}
if (mutation.isSuccess) {
return <Notice tone="info">{t('form.success')}</Notice>
}
return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<div className="flex flex-col gap-1.5">
<label htmlFor="device-code" className="text-sm text-frost">
{t('form.codeLabel')}
</label>
<div className="flex items-center gap-2 rounded-md border border-shoal bg-abyss px-3 py-2 focus-within:border-ice">
<span aria-hidden="true" className="font-code text-ice">
%
</span>
<input
id="device-code"
autoComplete="off"
autoCapitalize="characters"
inputMode="text"
maxLength={9}
placeholder="ABCD-1234"
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full bg-transparent font-code text-lg uppercase tracking-widest text-snow placeholder:text-frost/40 focus:outline-none"
/>
<span aria-hidden="true" className="font-code animate-pulse text-ice">
▌
</span>
</div>
</div>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={mutation.isPending}>
{t('form.submit')}
</Button>
</form>
)
}

View file

@ -0,0 +1,13 @@
import { api } from '../../shared/api/client'
/** Uppercases, strips spaces, and inserts the dash after 4 chars if missing (`abcd1234` -> `ABCD-1234`). */
export function normalizeUserCode(raw: string): string {
const cleaned = raw.trim().toUpperCase().replace(/\s+/g, '').replace(/-/g, '')
if (cleaned.length <= 4) return cleaned
return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`
}
/** Confirms a device code the mod displayed, linking that game session to the signed-in account. */
export async function confirmDevice(userCode: string): Promise<void> {
await api.request('/device/confirm', { method: 'POST', json: { user_code: userCode } })
}

View file

@ -0,0 +1,20 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { deviceLinkRu } from './ru'
export const deviceLinkEn = {
page: {
title: 'Link the game',
lead: "No password needed in the mod: it shows a short code, and you confirm it here.",
},
form: {
codeLabel: 'Code from the game',
submit: 'Link',
success: 'Game linked. You can go back to Minecraft, the mod is already signed in.',
expired: "Code not found or expired. Codes last 10 minutes, grab a new one in the mod.",
},
steps: {
one: 'Open the ClickGui in-game and switch to the account tab.',
two: 'The mod shows a short code, type it here.',
three: 'Confirm, the mod signs itself into your account.',
},
} satisfies Translation<typeof deviceLinkRu>

View file

@ -0,0 +1,17 @@
export const deviceLinkRu = {
page: {
title: 'Привязать игру',
lead: 'Пароль в мод вводить не нужно: мод показывает короткий код, ты подтверждаешь его здесь.',
},
form: {
codeLabel: 'Код из игры',
submit: 'Привязать',
success: 'Игра привязана. Можно возвращаться в Minecraft, мод уже вошёл в аккаунт.',
expired: 'Код не найден или устарел. Коды живут 10 минут, возьми новый в моде.',
},
steps: {
one: 'Открой ClickGui в игре и включи вкладку аккаунта.',
two: 'Мод покажет короткий код, набери его здесь.',
three: 'Подтверди, мод сам войдёт в твой аккаунт.',
},
} as const

View file

@ -0,0 +1,38 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { LinkDeviceForm } from '../LinkDeviceForm'
import { normalizeUserCode } from '../api'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
test('normalizes typed codes', () => {
expect(normalizeUserCode(' abcd1234 ')).toBe('ABCD-1234')
expect(normalizeUserCode('abcd-1234')).toBe('ABCD-1234')
})
test('confirms a code and shows success', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'POST /device/confirm': () => new Response(null, { status: 200 }),
})
renderApp([{ path: '/', element: <LinkDeviceForm /> }])
await user.type(await screen.findByLabelText('Код из игры'), 'abcd1234')
await user.click(screen.getByRole('button', { name: 'Привязать' }))
expect(await screen.findByText(/Игра привязана/)).toBeInTheDocument()
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/device/confirm'))?.[1]?.body
expect(body).toBe('{"user_code":"ABCD-1234"}')
})
test('expired code explains what to do', async () => {
const user = userEvent.setup()
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'POST /device/confirm': () => json({ error: 'unknown or expired user_code' }, 404),
})
renderApp([{ path: '/', element: <LinkDeviceForm /> }])
await user.type(await screen.findByLabelText('Код из игры'), 'ZZZZ-ZZZZ')
await user.click(screen.getByRole('button', { name: 'Привязать' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Коды живут 10 минут')
})

View file

@ -8,17 +8,17 @@ export const downloadEn = {
versionHint: 'v{version} · {size}',
page: {
title: 'Download LoVisual',
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file — no installers, no ads.',
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
reqTitle: 'What you need',
reqMc: 'Minecraft',
reqLoader: 'Fabric Loader',
reqApi: 'Fabric API',
reqOptionalTitle: 'Optional, for maximum FPS',
reqOptional: 'Sodium and Iris — the mod does not require them but plays nicely.',
reqOptional: 'Sodium and Iris: the mod does not require them but plays nicely.',
installTitle: 'Install in three steps',
step1: 'Download lovisual.jar with the button above.',
step2: 'Drop the file into your mods folder (create it if it is missing).',
step3: 'Launch Minecraft with the Fabric profile — the mod is active right away.',
step3: 'Launch Minecraft with the Fabric profile, the mod is active right away.',
},
notes: {
title: "What's new",

View file

@ -5,17 +5,17 @@ export const downloadRu = {
versionHint: 'v{version} · {size}',
page: {
title: 'Скачать LoVisual',
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл — без установщиков и без рекламы.',
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
reqTitle: 'Что понадобится',
reqMc: 'Minecraft',
reqLoader: 'Fabric Loader',
reqApi: 'Fabric API',
reqOptionalTitle: 'По желанию для максимального FPS',
reqOptional: 'Sodium и Iris — мод не требует их, но дружит с ними.',
reqOptional: 'Sodium и Iris: мод не требует их, но дружит с ними.',
installTitle: 'Установка в три шага',
step1: 'Скачай lovisual.jar кнопкой выше.',
step2: 'Положи файл в папку mods (создай её, если нет).',
step3: 'Запусти Minecraft с профилем Fabric — мод активен сразу.',
step3: 'Запусти Minecraft с профилем Fabric, мод активен сразу.',
},
notes: {
title: 'Что нового',

View file

@ -16,7 +16,7 @@ export function CommandHero() {
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28">
<h2 className="text-[2rem] leading-[1.1] sm:text-[2.8rem]">
{t('cloud.headlinePart1')}
{' '}— <br className="hidden lg:inline" />
{' '}<br className="hidden lg:inline" />
<span className="text-ice">{t('cloud.headlinePart2')}</span>
</h2>
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>

View file

@ -1,47 +0,0 @@
import { motion, useMotionValue, useSpring } from 'motion/react'
import { type ReactNode, useEffect, useRef } from 'react'
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
const MAX_DEG = 8
const REST = { x: 4, y: -6 }
const SPRING = { stiffness: 120, damping: 16, mass: 0.6 }
const clamp = (v: number) => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
/** Pointer-driven 3D tilt (max 8°) on a spring; rests at a slight angle for depth. */
export function Tilt({ children, className = '' }: { children: ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null)
const rx = useMotionValue(REST.x)
const ry = useMotionValue(REST.y)
const rotateX = useSpring(rx, SPRING)
const rotateY = useSpring(ry, SPRING)
useEffect(() => {
if (prefersReducedMotion()) return
const onMove = (e: PointerEvent) => {
const el = ref.current
if (!el || e.pointerType !== 'mouse') return
const r = el.getBoundingClientRect()
const nx = (e.clientX - (r.left + r.width / 2)) / (window.innerWidth / 2)
const ny = (e.clientY - (r.top + r.height / 2)) / (window.innerHeight / 2)
ry.set(clamp(nx * MAX_DEG))
rx.set(clamp(-ny * MAX_DEG))
}
const onLeave = () => {
rx.set(REST.x)
ry.set(REST.y)
}
window.addEventListener('pointermove', onMove, { passive: true })
document.documentElement.addEventListener('pointerleave', onLeave)
return () => {
window.removeEventListener('pointermove', onMove)
document.documentElement.removeEventListener('pointerleave', onLeave)
}
}, [rx, ry])
return (
<div ref={ref} className={className} style={{ perspective: 1400 }}>
<motion.div style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}>{children}</motion.div>
</div>
)
}

View file

@ -15,12 +15,12 @@ export const landingEn = {
visuals: 'for visuals',
themes: 'built-in themes',
},
tryIt: 'This is the real interface — hover and click around',
tryIt: 'This is the real interface: hover and click around',
},
themeStrip: {
eyebrow: 'Themes',
title: 'Hundreds of shades. Yours is one.',
lead: 'Every built-in theme, straight from the mod. Click one — the ClickGui and the whole site recolor on the fly.',
lead: 'Every built-in theme, straight from the mod. Click one: the ClickGui and the whole site recolor on the fly.',
cta: 'Build your own theme',
current: 'Now',
swatches: 'Built-in themes',
@ -39,14 +39,14 @@ export const landingEn = {
howItWorks: {
slotsTitle: 'Four slots in the cloud',
slotsDesc:
'Save up to four sets of settings and load any of them on another computer — every slot has a code a friend can load too.',
'Save up to four sets of settings and load any of them on another computer: every slot has a code a friend can load too.',
slotsAriaLabel: 'Example slots',
emptySlot: 'empty',
slotNamePvp: 'pvp',
slotNameStream: 'stream',
slotNameLazy: 'lazy',
linkTitle: 'Linking the game',
linkStep1: 'Open the account tab in the mod — a code will appear there.',
linkStep1: 'Open the account tab in the mod, a code will appear there.',
linkStep2: 'Enter the code on the "Link game" page.',
linkStep3: "Done: the mod signs in to your account by itself, no password needed.",
showcaseText: 'Any config from the showcase can be copied into a free slot with one button.',
@ -56,7 +56,7 @@ export const landingEn = {
eyebrow: 'HUD',
title: 'Arrange the widgets like in game',
lead: 'The same widgets the mod draws: FPS, coordinates, module list, keys and ping. Drag them with the mouse or arrow keys.',
caption: 'Drag it — just like in game.',
caption: 'Drag it, just like in game.',
reset: 'Reset',
widgetFps: 'FPS',
widgetCoords: 'Coordinates',
@ -69,7 +69,7 @@ export const landingEn = {
wall: {
eyebrow: 'Modules',
title: '88 modules. 52 are about looks.',
lead: 'The whole category wall. Hover a visuals module — I\'ll tell you what it does.',
lead: 'The whole category wall. Hover a visuals module: I\'ll tell you what it does.',
columnsAria: 'Module categories',
},
showcase: {
@ -95,7 +95,7 @@ export const landingEn = {
q5: 'How do I move settings to another computer?',
a5: 'Save a config to a cloud slot and load it by code on any other PC.',
downloadTitle: 'Ready to try it?',
downloadLead: 'One .jar straight from GitHub — just drop it into your mods folder.',
downloadLead: 'One .jar straight from GitHub, just drop it into your mods folder.',
downloadCta: 'Download for free',
downloadNote: 'Minecraft 26.2 · Fabric · the latest release',
releaseLink: 'Releases and versions',

View file

@ -12,12 +12,12 @@ export const landingRu = {
visuals: 'про визуал',
themes: 'встроенных тем',
},
tryIt: 'Это настоящий интерфейс — наведи и покликай',
tryIt: 'Это настоящий интерфейс: наведи и покликай',
},
themeStrip: {
eyebrow: 'Темы',
title: 'Сотни оттенков. Твой — один.',
lead: 'Все встроенные темы прямо из мода. Нажми на любую — ClickGui и весь сайт перекрасятся на лету.',
title: 'Сотни оттенков. Твой один.',
lead: 'Все встроенные темы прямо из мода. Нажми на любую: ClickGui и весь сайт перекрасятся на лету.',
cta: 'Собрать свою тему',
current: 'Сейчас',
swatches: 'Встроенные темы',
@ -36,14 +36,14 @@ export const landingRu = {
howItWorks: {
slotsTitle: 'Четыре слота в облаке',
slotsDesc:
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере — у каждого слота есть код, по которому его может загрузить и друг.',
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере: у каждого слота есть код, по которому его может загрузить и друг.',
slotsAriaLabel: 'Пример слотов',
emptySlot: 'пусто',
slotNamePvp: 'pvp',
slotNameStream: 'стрим',
slotNameLazy: 'ленивый',
linkTitle: 'Привязка игры',
linkStep1: 'В моде открой вкладку аккаунта — там появится код.',
linkStep1: 'В моде открой вкладку аккаунта, там появится код.',
linkStep2: 'Введи код на странице «Привязать игру».',
linkStep3: 'Готово: мод входит в аккаунт сам, пароль ему не нужен.',
showcaseText: 'Любой конфиг из витрины можно скопировать в свободный слот одной кнопкой.',
@ -53,7 +53,7 @@ export const landingRu = {
eyebrow: 'HUD',
title: 'Расставь элементы как в игре',
lead: 'Те же виджеты, что рисует мод: FPS, координаты, список модулей, клавиши и пинг. Таскай их мышкой или стрелками.',
caption: 'Перетащи — так же, как в игре.',
caption: 'Перетащи так же, как в игре.',
reset: 'Сбросить',
widgetFps: 'FPS',
widgetCoords: 'Координаты',
@ -61,12 +61,12 @@ export const landingRu = {
widgetKeys: 'Нажатия клавиш',
widgetPing: 'Пинг',
unitMs: 'мс',
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки — двигать.',
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки: двигать.',
},
wall: {
eyebrow: 'Модули',
title: '88 модулей. 52 — про красоту.',
lead: 'Вся витрина категорий. Наведи на визуальный модуль — расскажу, что он делает.',
title: '88 модулей. 52 про красоту.',
lead: 'Вся витрина категорий. Наведи на визуальный модуль: расскажу, что он делает.',
columnsAria: 'Категории модулей',
},
showcase: {
@ -86,13 +86,13 @@ export const landingRu = {
q2: 'Какие версии Minecraft поддерживаются?',
a2: 'Minecraft 26.2 на загрузчике Fabric.',
q3: 'Нужен ли аккаунт?',
a3: 'Для самого мода — нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
a3: 'Для самого мода: нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
q4: 'Это легитно? Забанят ли?',
a4: 'LoVisual — визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
a4: 'LoVisual, визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
q5: 'Как перенести настройки на другой компьютер?',
a5: 'Сохрани конфиг в облачный слот и загрузи его по коду на любом другом ПК.',
downloadTitle: 'Готов попробовать?',
downloadLead: 'Один .jar прямо с GitHub — просто положи его в папку mods.',
downloadLead: 'Один .jar прямо с GitHub, просто положи его в папку mods.',
downloadCta: 'Скачать бесплатно',
downloadNote: 'Minecraft 26.2 · Fabric · самый свежий релиз',
releaseLink: 'Список релизов и версий',

View file

@ -1,21 +1,24 @@
import { useTranslation } from 'react-i18next'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../download/api'
import '../styles/landing.css'
import { themeToCssVars } from '../../../clickgui/themeVars'
import { useActiveTheme } from '../../../themes/useActiveTheme'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../../download/api'
import '../../styles/landing.css'
const KEYS = ['1', '2', '3', '4', '5'] as const
export function FaqDownload() {
const { t } = useTranslation('landing')
const { theme } = useActiveTheme()
const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) }))
return (
<section aria-labelledby="faq-title" className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
<section aria-labelledby="faq-title" style={themeToCssVars(theme)} className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
<div className="flex min-w-0 flex-col gap-6">
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('faq.eyebrow')}</span>
<h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2>
<div className="flex flex-col divide-y divide-white/10 border-y border-white/10">
<div className="flex flex-col gap-2">
{items.map((it) => (
<details key={it.q} className="lv-faq-item group py-3">
<details key={it.q} className="lv-faq-item lv-glass">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-base font-semibold text-snow">
{it.q}
<span aria-hidden="true" className="lv-faq-marker" />
@ -26,7 +29,7 @@ export function FaqDownload() {
</div>
</div>
<div className="lv-download-band flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
<div className="lv-download-band lv-glass flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
<h3 className="text-[1.75rem] leading-tight sm:text-[2.1rem]">{t('faq.downloadTitle')}</h3>
<p className="max-w-[42ch] text-lg text-frost">{t('faq.downloadLead')}</p>
<a href={DOWNLOAD_URL} download className="lv-cta-primary">

View file

@ -1,11 +1,11 @@
import { useRef, useState } from 'react'
import { useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { themeToCssVars } from '../../clickgui/themeVars'
import { parseArgb } from '../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme'
import { VoxelScene } from '../effects/VoxelScene'
import '../styles/landing.css'
import { Button } from '../../../../shared/ui/Button'
import { themeToCssVars } from '../../../clickgui/themeVars'
import { parseArgb } from '../../../themes/types'
import { useActiveTheme } from '../../../themes/useActiveTheme'
import { VoxelScene } from '../../effects/VoxelScene'
import '../../styles/landing.css'
const STEP = 8
/** Fallback playfield size, used before the box is measured (and under jsdom). */
@ -26,6 +26,31 @@ const START: Record<WidgetId, Pos> = {
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
/** Pull every widget back inside the measured field (mount, resize, reset). */
function fitToField(
state: Record<WidgetId, Pos>,
field: HTMLDivElement | null,
widgets: Partial<Record<WidgetId, HTMLElement | null>>,
): Record<WidgetId, Pos> {
const w = field?.clientWidth || FIELD.w
const h = field?.clientHeight || FIELD.h
const next = { ...state }
let changed = false
for (const id of Object.keys(next) as WidgetId[]) {
const el = widgets[id]
const maxX = Math.max(0, w - (el?.offsetWidth || WIDGET.w))
const maxY = Math.max(0, h - (el?.offsetHeight || WIDGET.h))
const x = clamp(next[id].x, maxX)
const y = clamp(next[id].y, maxY)
if (x !== next[id].x || y !== next[id].y) {
next[id] = { x, y }
changed = true
}
}
return changed ? next : state
}
/** Enabled modules as they would appear in the mod's ModuleList panel. */
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
@ -114,6 +139,7 @@ export function HudPlayground() {
const { theme } = useActiveTheme()
const { r, g, b } = parseArgb(theme.theme.accent)
const fieldRef = useRef<HTMLDivElement>(null)
const widgetRefs = useRef<Partial<Record<WidgetId, HTMLElement | null>>>({})
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null)
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
@ -124,6 +150,16 @@ export function HudPlayground() {
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
}
useLayoutEffect(() => {
const fit = () => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current))
fit()
const field = fieldRef.current
if (!field || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(fit)
ro.observe(field)
return () => ro.disconnect()
}, [])
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => {
const { maxX, maxY } = bounds(el)
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } }))
@ -169,7 +205,7 @@ export function HudPlayground() {
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span>
<h2 id="hud-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('hud.title')}</h2>
<p className="max-w-[42ch] text-lg text-frost">{t('hud.lead')}</p>
<Button variant="quiet" className="mt-2" onClick={() => setPos(START)}>
<Button variant="quiet" className="mt-2" onClick={() => setPos(fitToField(START, fieldRef.current, widgetRefs.current))}>
{t('hud.reset')}
</Button>
</div>
@ -185,6 +221,9 @@ export function HudPlayground() {
{widgets.map((w) => (
<div
key={w.id}
ref={(el) => {
widgetRefs.current[w.id] = el
}}
role="button"
tabIndex={0}
title={w.label}

View file

@ -1,13 +1,12 @@
import { useTranslation } from 'react-i18next'
import { CATEGORIES } from '../../clickgui/parts/modules'
import '../styles/landing.css'
import { CATEGORIES } from '../../../clickgui/parts/modules'
import '../../styles/landing.css'
/** Each column scrolls at its own pace; the list is duplicated so the loop is seamless. */
const SPEED_MS = [42, 30, 50, 36]
export function ModuleWall() {
const { t } = useTranslation('landing')
const { t: tc } = useTranslation('clickgui')
return (
<section aria-labelledby="wall-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-14">
@ -23,18 +22,14 @@ export function ModuleWall() {
<h3 className="font-code text-xs uppercase tracking-[0.18em] text-ice">{cat.name}</h3>
<div className="lv-wall-viewport relative h-[300px] overflow-hidden">
<ul className="lv-wall-track flex flex-col gap-1.5" style={{ ['--dur' as string]: `${SPEED_MS[i % SPEED_MS.length]}s` }}>
{[...cat.modules, ...cat.modules].map((m, idx) => {
const desc = cat.id === 'visuals' ? tc(`desc.${m.name}`) : undefined
return (
<li
key={`${m.name}-${idx}`}
title={desc}
className="truncate rounded-md border border-white/5 bg-black/30 px-2.5 py-1.5 text-sm text-snow/90 hover:border-ice/40"
>
{m.name}
</li>
)
})}
{[...cat.modules, ...cat.modules].map((m, idx) => (
<li
key={`${m.name}-${idx}`}
className="truncate rounded-md border border-white/5 bg-black/30 px-2.5 py-1.5 text-sm text-snow/90 hover:border-ice/40"
>
{m.name}
</li>
))}
</ul>
</div>
</div>

View file

@ -1,25 +1,24 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { api } from '../../../shared/api/client'
import '../styles/landing.css'
type ShowcaseItem = { id: string; name: string; author: string; downloads: number }
import { browse } from '../../../showcase/api'
import type { Listing } from '../../../showcase/types'
import '../../styles/landing.css'
/** Placeholder cards so the section never collapses while loading or when the API is down. */
const EXAMPLES: ShowcaseItem[] = [
{ id: 'e1', name: 'Aura / Trails', author: 'example', downloads: 128 },
{ id: 'e2', name: 'Clean PvP', author: 'example', downloads: 96 },
{ id: 'e3', name: 'Stream Safe', author: 'example', downloads: 74 },
const EXAMPLES: Listing[] = [
{ id: 'e1', title: 'Aura / Trails', config_name: 'aura', description: '', copies_count: 128, published_at: '', author: { nick: 'example', avatar_url: null } },
{ id: 'e2', title: 'Clean PvP', config_name: 'pvp', description: '', copies_count: 96, published_at: '', author: { nick: 'example', avatar_url: null } },
{ id: 'e3', title: 'Stream Safe', config_name: 'stream', description: '', copies_count: 74, published_at: '', author: { nick: 'example', avatar_url: null } },
]
export function ShowcaseTeaser() {
const { t } = useTranslation('landing')
const { data, isError } = useQuery({
queryKey: ['showcase', 'popular'],
queryFn: () => api.request<ShowcaseItem[]>('/showcase?sort=popular'),
queryKey: ['showcase', 'popular', 0, null],
queryFn: () => browse('popular', 0),
})
const real = !isError && Array.isArray(data) ? data.slice(0, 3) : []
const real = !isError && data ? data.items.slice(0, 3) : []
const cards = real.length >= 3 ? real : EXAMPLES
const isExample = real.length < 3
@ -35,11 +34,11 @@ export function ShowcaseTeaser() {
{cards.map((c) => (
<li key={c.id} className="lv-config-card flex flex-col gap-3 rounded-panel border border-white/10 bg-black/30 p-5">
<div className="flex items-center justify-between gap-2">
<span className="font-display text-lg font-bold text-snow">{c.name}</span>
<span className="font-display text-lg font-bold text-snow">{c.title}</span>
{isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null}
</div>
<span className="text-sm text-frost">{t('showcase.by', { author: c.author })}</span>
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.downloads })}</span>
<span className="text-sm text-frost">{t('showcase.by', { author: c.author?.nick ?? t('showcase.example') })}</span>
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.copies_count })}</span>
</li>
))}
</ul>

View file

@ -1,17 +1,16 @@
import { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../shared/ui/Button'
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
import { DOWNLOAD_URL } from '../../download/api'
import { PRESETS } from '../../themes/presets.generated'
import { parseArgb } from '../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme'
import { ParticleField } from '../effects/ParticleField'
import { Tilt } from '../effects/Tilt'
import { useInView } from '../effects/useInView'
import { VoxelScene } from '../effects/VoxelScene'
import '../styles/landing.css'
import { ButtonLink } from '../../../../shared/ui/Button'
import { ClickGuiWindow } from '../../../clickgui/ClickGuiWindow'
import { DOWNLOAD_URL } from '../../../download/api'
import { PRESETS } from '../../../themes/presets.generated'
import { parseArgb } from '../../../themes/types'
import { useActiveTheme } from '../../../themes/useActiveTheme'
import { ParticleField } from '../../effects/ParticleField'
import { useInView } from '../../effects/useInView'
import { VoxelScene } from '../../effects/VoxelScene'
import '../../styles/landing.css'
const rise = (delay: number) => ({
initial: { opacity: 0, y: 24 },
@ -45,13 +44,13 @@ export function Hero() {
<section
ref={sectionRef}
aria-labelledby="hero-title"
className="lv-bleed relative isolate flex min-h-[calc(100svh-3.75rem)] items-center overflow-hidden"
className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh items-center overflow-hidden pt-(--topbar-h)"
>
<VoxelScene accent={accent} />
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
<ParticleField color={accent} />
<div className="relative mx-auto grid w-full max-w-[1200px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.12fr)] lg:gap-8 lg:py-16">
<div className="relative mx-auto grid w-full max-w-[1120px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:py-16">
<div className="flex min-w-0 flex-col items-start gap-6">
<motion.span
{...rise(0)}
@ -94,9 +93,7 @@ export function Hero() {
transition={{ duration: 0.9, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="flex min-w-0 flex-col items-center gap-4"
>
<Tilt className="w-full max-w-[660px]">
<ClickGuiWindow paused={!inView} />
</Tilt>
<ClickGuiWindow paused={!inView} />
<p className="flex items-center gap-2 text-sm text-frost">
<span className="lv-live-dot" aria-hidden="true" />
{t('hero.tryIt')}

View file

@ -1,13 +1,13 @@
import { motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ButtonLink } from '../../../shared/ui/Button'
import { themeToCssVars } from '../../clickgui/themeVars'
import { PRESETS } from '../../themes/presets.generated'
import { argbToCss, type ThemeColors } from '../../themes/types'
import { useActiveTheme } from '../../themes/useActiveTheme'
import { useInView } from '../effects/useInView'
import '../styles/landing.css'
import { ButtonLink } from '../../../../shared/ui/Button'
import { themeToCssVars } from '../../../clickgui/themeVars'
import { PRESETS } from '../../../themes/presets.generated'
import { argbToCss, type ThemeColors } from '../../../themes/types'
import { useActiveTheme } from '../../../themes/useActiveTheme'
import { useInView } from '../../effects/useInView'
import '../../styles/landing.css'
const PALETTE_KEYS: (keyof ThemeColors)[] = [
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',

View file

@ -174,12 +174,61 @@
.lv-config-card { transition: transform 220ms ease, border-color 220ms ease; }
.lv-config-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--color-ice) 40%, transparent); }
.lv-download-band {
.lv-glass {
border: 1px solid transparent;
background:
radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--page-accent-glow, #6cf) 22%, transparent), transparent 60%),
linear-gradient(180deg, #0e1430, #070a18);
border: 1px solid color-mix(in srgb, var(--color-ice) 22%, transparent);
linear-gradient(var(--gui-window-angle, 180deg),
color-mix(in srgb, var(--gui-window-from, #12191b) 72%, transparent),
color-mix(in srgb, var(--gui-window-to, #12191b) 72%, transparent)) padding-box,
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent, #6cf) 55%, transparent),
var(--gui-stroke-from, #2a3a3f) 35%, var(--gui-stroke-to, #2a3a3f) 70%,
color-mix(in srgb, var(--gui-accent, #6cf) 30%, transparent)) border-box;
box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 26px -10px var(--gui-accent-glow, transparent);
backdrop-filter: blur(12px) saturate(1.25);
}
.lv-download-band { position: relative; overflow: hidden; }
.lv-download-band::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--gui-accent, #6cf) 26%, transparent), transparent 60%);
}
.lv-download-band > * { position: relative; }
.lv-faq-item { border-radius: 10px; padding: 0.85rem 1rem; transition: box-shadow 200ms ease; }
.lv-faq-item:hover, .lv-faq-item[open] { box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 30px -6px var(--gui-accent-glow, transparent); }
/* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
.lv-lower { position: relative; isolation: isolate; }
.lv-lower-bg {
position: absolute; inset: 0 calc(50% - 50vw); z-index: -1; pointer-events: none;
background:
radial-gradient(60% 30% at 15% 12%, color-mix(in srgb, var(--page-accent-glow, #6cf) 30%, transparent), transparent 70%),
radial-gradient(55% 28% at 88% 45%, color-mix(in srgb, var(--color-ember) 14%, transparent), transparent 70%),
radial-gradient(60% 30% at 20% 82%, color-mix(in srgb, var(--page-accent-glow, #6cf) 24%, transparent), transparent 70%),
linear-gradient(180deg, #0d1416, #0a0f1f 40%, #120d24 75%, #0d1416);
-webkit-mask-image: linear-gradient(180deg, transparent, #000 6%, #000 94%, transparent);
mask-image: linear-gradient(180deg, transparent, #000 6%, #000 94%, transparent);
}
.lv-lower-bg::before {
content: ""; position: absolute; inset: 0; opacity: 0.5;
background-image:
linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px);
background-size: 48px 48px;
-webkit-mask-image: radial-gradient(80% 60% at 50% 50%, #000, transparent);
mask-image: radial-gradient(80% 60% at 50% 50%, #000, transparent);
}
.lv-lower-bg::after {
content: ""; position: absolute; inset: 0;
background-image:
radial-gradient(1.5px 1.5px at 12% 20%, var(--color-ice), transparent),
radial-gradient(1.5px 1.5px at 78% 34%, var(--color-ice), transparent),
radial-gradient(1px 1px at 44% 58%, #fff, transparent),
radial-gradient(1.5px 1.5px at 90% 76%, var(--color-ice), transparent),
radial-gradient(1px 1px at 26% 90%, #fff, transparent);
background-size: 100% 900px; opacity: 0.55;
animation: lv-motes 40s linear infinite alternate;
}
@keyframes lv-motes { from { transform: translateY(0); } to { transform: translateY(-40px); } }
@media (prefers-reduced-motion: reduce) { .lv-lower-bg::after { animation: none; } }
.lv-faq-item > summary::-webkit-details-marker { display: none; }
.lv-faq-marker { width: 10px; height: 10px; border-right: 2px solid var(--color-ice); border-bottom: 2px solid var(--color-ice); transform: rotate(45deg); transition: transform 200ms ease; }

View file

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { I18nextProvider } from 'react-i18next'
import { CommandHero } from '../CommandHero'
import { Hero } from '../sections/Hero'
import { Hero } from '../sections/hero/Hero'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { initI18n } from '../../../app/i18n'

View file

@ -4,9 +4,9 @@ import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, expect, test, vi } from 'vitest'
import { mockFetch } from '../../../test/fetch'
import { ParticleField } from '../effects/ParticleField'
import { HudPlayground } from '../sections/HudPlayground'
import { ModuleWall } from '../sections/ModuleWall'
import { ShowcaseTeaser } from '../sections/ShowcaseTeaser'
import { HudPlayground } from '../sections/explore/HudPlayground'
import { ModuleWall } from '../sections/explore/ModuleWall'
import { ShowcaseTeaser } from '../sections/explore/ShowcaseTeaser'
const reduceMotion = () =>
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
@ -44,7 +44,7 @@ test('HudWidget stays inside the playfield bounds', () => {
test('ModuleWall lists all four category headings', () => {
reduceMotion()
withQuery(<ModuleWall />)
for (const name of ['Combat', 'Visuals', 'Player', 'Misc']) {
for (const name of ['Combat', 'Player', 'Visuals', 'Other']) {
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument()
}
})

View file

@ -0,0 +1,13 @@
import { api } from '../../shared/api/client'
export type PublicProfile = {
id: string
display_nick: string
avatar_url: string | null
created_at: string
badges: string[]
}
export function getProfile(id: string): Promise<PublicProfile> {
return api.request(`/users/${id}`)
}

View file

@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ApiError } from '../../../shared/api/client'
import { Notice } from '../../../shared/ui/Notice'
import { ListingList } from '../../showcase/components/ListingList'
import { getProfile } from '../api'
export function PublicProfileView({ id }: { id: string }) {
const { t, i18n } = useTranslation('profile')
const { data, error, isPending } = useQuery({ queryKey: ['profile', id], queryFn: () => getProfile(id), retry: false })
if (isPending) return <p className="text-frost">…</p>
if (error || !data) {
const missing = error instanceof ApiError && error.status === 404
return <Notice tone="error">{missing ? t('notFound') : t('error')}</Notice>
}
const joined = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'long' }).format(new Date(data.created_at))
return (
<div className="flex flex-col gap-10">
<div className="flex items-center gap-5 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6">
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
{data.avatar_url ? (
<img src={data.avatar_url} alt="" className="h-full w-full object-cover" />
) : (
<span aria-hidden="true">{data.display_nick.slice(0, 1).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h1 className="truncate font-display text-2xl">{data.display_nick}</h1>
<p className="mt-1 text-sm text-frost">{t('memberSince', { date: joined })}</p>
{data.badges.includes('early') ? (
<span className="mt-2 inline-block rounded-full border border-ice/40 px-3 py-0.5 font-code text-xs text-ice">
{t('badgeEarly')}
</span>
) : null}
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="font-display text-xl">{t('configs')}</h2>
<ListingList author={id} />
</section>
</div>
)
}

View file

@ -0,0 +1,7 @@
export const profileEn = {
memberSince: 'On LoVisual since {{date}}',
badgeEarly: 'One of the first 1000',
configs: "Player's configs",
notFound: 'No such player.',
error: 'Could not load the profile. Try again later.',
}

View file

@ -0,0 +1,7 @@
export const profileRu = {
memberSince: 'В LoVisual с {{date}}',
badgeEarly: 'Один из первых 1000',
configs: 'Конфиги игрока',
notFound: 'Такого игрока нет.',
error: 'Не удалось загрузить профиль. Попробуй позже.',
}

View file

@ -0,0 +1,27 @@
import { screen } from '@testing-library/react'
import { expect, test } from 'vitest'
import { json, mockFetch } from '../../../test/fetch'
import { renderApp } from '../../../test/render'
import { PublicProfileView } from '../components/PublicProfileView'
const profile = { id: 'u1', display_nick: 'loki', avatar_url: null, created_at: '2026-01-05T10:00:00Z', badges: ['early'] }
test('shows nick, early badge and the player configs', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /users/u1': () => json(profile),
'GET /showcase': () => json({ items: [], page: 0, has_more: false }),
})
renderApp([{ path: '/', element: <PublicProfileView id="u1" /> }])
expect(await screen.findByText('loki')).toBeInTheDocument()
expect(screen.getByText('Один из первых 1000')).toBeInTheDocument()
})
test('unknown player shows not found', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /users/nope': () => json({ error: 'no such account' }, 404),
})
renderApp([{ path: '/', element: <PublicProfileView id="nope" /> }])
expect(await screen.findByText('Такого игрока нет.')).toBeInTheDocument()
})

View file

@ -0,0 +1,16 @@
import { api } from '../../shared/api/client'
import type { ListingDetail, ShowcasePage } from './types'
export function browse(sort: 'new' | 'popular', page: number, author?: string): Promise<ShowcasePage> {
const params = new URLSearchParams({ sort, page: String(page) })
if (author) params.set('author', author)
return api.request(`/showcase?${params.toString()}`)
}
export function getListing(id: string): Promise<ListingDetail> {
return api.request(`/showcase/${id}`)
}
export function copyToSlot(id: string, slot: number): Promise<unknown> {
return api.request(`/showcase/${id}/copy`, { method: 'POST', json: { slot } })
}

View file

@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router'
import { Notice } from '../../../shared/ui/Notice'
import { ApiError } from '../../../shared/api/client'
import { useSession } from '../../auth/session'
import { listSlots } from '../../configs/api'
import { copyToSlot } from '../api'
const SLOTS = [1, 2, 3, 4] as const
export function CopyToSlot({ listingId }: { listingId: string }) {
const { t } = useTranslation('showcase')
const { status } = useSession()
const location = useLocation()
const client = useQueryClient()
const [error, setError] = useState<string | null>(null)
const [copiedSlot, setCopiedSlot] = useState<number | null>(null)
const { data: slots } = useQuery({ queryKey: ['configs'], queryFn: listSlots, enabled: status === 'authenticated' })
const occupied = new Set((slots ?? []).map((s) => s.slot))
const mutation = useMutation({
mutationFn: (slot: number) => copyToSlot(listingId, slot),
onSuccess: (_data, slot) => {
setError(null)
setCopiedSlot(slot)
client.invalidateQueries({ queryKey: ['configs'] })
},
onError: (err) => {
setError(err instanceof ApiError && err.status === 409 ? t('copy.conflict') : t('page.error'))
},
})
if (status !== 'authenticated') {
return (
<div className="flex flex-col gap-2">
<p className="text-frost">{t('copy.loginPrompt')}</p>
<Link to="/login" state={{ from: location.pathname }} className="text-ice hover:underline">
{t('copy.login')}
</Link>
</div>
)
}
return (
<div className="flex flex-col gap-3">
<h2 className="font-display text-lg text-snow">{t('copy.heading')}</h2>
{copiedSlot ? (
<Notice tone="info">
{t('copy.success', { slot: copiedSlot })}{' '}
<Link to="/configs" className="text-ice hover:underline">
{t('copy.openConfigs')}
</Link>
</Notice>
) : null}
{error ? <Notice tone="error">{error}</Notice> : null}
<div role="group" aria-label={t('copy.heading')} className="inline-flex overflow-hidden rounded-md border border-shoal">
{SLOTS.map((slot) => (
<button
key={slot}
type="button"
disabled={occupied.has(slot) || mutation.isPending}
title={occupied.has(slot) ? t('copy.occupied') : undefined}
onClick={() => mutation.mutate(slot)}
className="border-r border-shoal px-4 py-2 text-sm font-semibold text-snow last:border-r-0 hover:bg-ice/10 disabled:cursor-not-allowed disabled:text-frost/50 disabled:hover:bg-transparent"
>
{t('copy.slot', { slot })}
</button>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { Notice } from '../../../shared/ui/Notice'
import { getListing } from '../api'
import { copiesText } from '../copies'
import { CopyToSlot } from './CopyToSlot'
function formatDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
export function ListingDetail({ id }: { id: string }) {
const { t, i18n } = useTranslation('showcase')
const { data, isError, isPending } = useQuery({ queryKey: ['listing', id], queryFn: () => getListing(id) })
if (isPending) return null
if (isError || !data) {
return (
<div className="flex flex-col gap-4">
<Notice tone="error">{t('detail.notFound')}</Notice>
<Link to="/showcase" className="text-ice hover:underline">
{t('detail.back')}
</Link>
</div>
)
}
return (
<article className="flex flex-col gap-4 rounded-(--radius-panel) border border-shoal bg-slate/60 px-5 py-6 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]">
<Link to="/showcase" className="text-sm text-ice hover:underline">
{t('detail.back')}
</Link>
<h1 className="font-display text-3xl text-snow">{data.title}</h1>
<p className="font-code text-sm text-ice">{data.config_name}</p>
<p className="whitespace-pre-line text-frost">{data.description}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-frost">
{data.author && data.author_id ? (
<Link to={`/u/${data.author_id}`} className="text-ice underline-offset-4 hover:underline">
{data.author.nick}
</Link>
) : (
<span>{data.author ? data.author.nick : t('item.authorHidden')}</span>
)}
<span aria-hidden="true">·</span>
<span>{formatDate(data.published_at)}</span>
<span aria-hidden="true">·</span>
<span>{copiesText(t, i18n.language, data.copies_count)}</span>
</div>
<CopyToSlot listingId={data.id} />
</article>
)
}

View file

@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { copiesText } from '../copies'
import type { Listing } from '../types'
function formatDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
}
export function ListingItem({ listing }: { listing: Listing }) {
const { t, i18n } = useTranslation('showcase')
const copies = copiesText(t, i18n.language, listing.copies_count)
return (
<article className="flex flex-col gap-2 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-4 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)] transition-colors hover:border-ice/60">
<Link to={`/showcase/${listing.id}`} className="flex flex-col gap-2">
<h3 className="font-display text-lg text-snow">{listing.title}</h3>
<p className="font-code text-xs text-ice">{listing.config_name}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-frost">
<span>{listing.author ? listing.author.nick : t('item.authorHidden')}</span>
<span aria-hidden="true">·</span>
<span>{copies}</span>
<span aria-hidden="true">·</span>
<span>{formatDate(listing.published_at)}</span>
</div>
</Link>
</article>
)
}

View file

@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
import { browse } from '../api'
import { ListingItem } from './ListingItem'
function readSort(params: URLSearchParams): 'new' | 'popular' {
return params.get('sort') === 'popular' ? 'popular' : 'new'
}
function readPage(params: URLSearchParams): number {
const n = Number(params.get('page'))
return Number.isInteger(n) && n >= 0 ? n : 0
}
export function ListingList({ author }: { author?: string } = {}) {
const { t } = useTranslation('showcase')
const [params, setParams] = useSearchParams()
const sort = readSort(params)
const page = readPage(params)
const { data, isError, isPending } = useQuery({
queryKey: ['showcase', sort, page, author ?? null],
queryFn: () => browse(sort, page, author),
})
function setSort(next: 'new' | 'popular') {
setParams((p) => {
const q = new URLSearchParams(p)
q.set('sort', next)
q.set('page', '0')
return q
})
}
function setPage(next: number) {
setParams((p) => {
const q = new URLSearchParams(p)
q.set('page', String(next))
return q
})
}
return (
<div className="flex flex-col gap-6">
<div className="flex gap-2" role="group" aria-label={t('page.title')}>
<Button variant={sort === 'new' ? 'primary' : 'quiet'} aria-pressed={sort === 'new'} onClick={() => setSort('new')}>
{t('page.sortNew')}
</Button>
<Button variant={sort === 'popular' ? 'primary' : 'quiet'} aria-pressed={sort === 'popular'} onClick={() => setSort('popular')}>
{t('page.sortPopular')}
</Button>
</div>
{isError ? <Notice tone="error">{t('page.error')}</Notice> : null}
{!isError && !isPending && data && data.items.length === 0 ? <Notice tone="info">{t('page.empty')}</Notice> : null}
{data && data.items.length > 0 ? (
<ul className="grid grid-cols-1 gap-4 min-[720px]:grid-cols-2">
{data.items.map((item) => (
<li key={item.id}>
<ListingItem listing={item} />
</li>
))}
</ul>
) : null}
<div className="flex items-center gap-3">
<Button variant="quiet" disabled={page === 0} onClick={() => setPage(page - 1)}>
{t('page.back')}
</Button>
<Button variant="quiet" disabled={!data?.has_more} onClick={() => setPage(page + 1)}>
{t('page.next')}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,17 @@
import type { TFunction } from 'i18next'
const RU_PLURAL = new Intl.PluralRules('ru')
/** Russian pluralization for "Скопировали N раз/раза/раз", exported for a focused unit test. */
export function copiesLabel(count: number): string {
const form = RU_PLURAL.select(count)
const word = form === 'one' ? 'раз' : form === 'few' ? 'раза' : 'раз'
return `Скопировали ${count} ${word}`
}
export function copiesText(t: TFunction<'showcase'>, lang: string, count: number): string {
if (lang === 'ru') return copiesLabel(count)
const form = RU_PLURAL.select(count)
const key = form === 'one' ? 'item.copiesOne' : form === 'few' ? 'item.copiesFew' : 'item.copiesMany'
return t(key, { count })
}

View file

@ -0,0 +1,32 @@
export const showcaseEn = {
page: {
title: 'Showcase',
lead: 'Configs shared by other players. Like one? Copy it into your own slot.',
sortNew: 'Newest',
sortPopular: 'Popular',
back: 'Back',
next: 'Next',
empty: 'The showcase is empty for now. Publish your config on the My configs page.',
error: 'The showcase is unavailable. Try refreshing the page later.',
},
item: {
authorHidden: 'Author hidden',
copiesOne: 'Copied {{count}} time',
copiesFew: 'Copied {{count}} times',
copiesMany: 'Copied {{count}} times',
},
detail: {
back: 'Back to showcase',
notFound: 'This config does not exist or is hidden.',
},
copy: {
heading: 'Copy to my slot',
slot: 'Slot {{slot}}',
occupied: 'Occupied',
loginPrompt: 'Log in to copy this config to your account',
login: 'Log in',
success: 'Copied to slot {{slot}}',
openConfigs: 'Open my configs',
conflict: 'That slot is already taken. Pick a free one or clear a slot in the mod.',
},
} as const

View file

@ -0,0 +1,32 @@
export const showcaseRu = {
page: {
title: 'Витрина',
lead: 'Конфиги, которыми поделились игроки. Понравился, скопируй в свой слот.',
sortNew: 'Новые',
sortPopular: 'Популярные',
back: 'Назад',
next: 'Дальше',
empty: 'На витрине пока пусто. Опубликуй свой конфиг на странице «Мои конфиги».',
error: 'Витрина недоступна. Попробуй обновить страницу позже.',
},
item: {
authorHidden: 'Автор скрыт',
copiesOne: 'Скопировали {{count}} раз',
copiesFew: 'Скопировали {{count}} раза',
copiesMany: 'Скопировали {{count}} раз',
},
detail: {
back: 'Назад к витрине',
notFound: 'Такого конфига нет или он скрыт.',
},
copy: {
heading: 'Скопировать себе',
slot: 'Слот {{slot}}',
occupied: 'Занят',
loginPrompt: 'Войди, чтобы скопировать конфиг к себе',
login: 'Войти',
success: 'Скопировано в слот {{slot}}',
openConfigs: 'Открыть мои конфиги',
conflict: 'Этот слот уже занят. Выбери свободный или освободи слот в моде.',
},
} as const

View file

@ -0,0 +1,87 @@
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { copiesLabel } from '../copies'
import { ListingList } from '../components/ListingList'
import { CopyToSlot } from '../components/CopyToSlot'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
const listing = {
id: 'l1',
title: 'Aura / Trails',
description: 'Мой любимый набор',
config_name: 'pvp',
copies_count: 3,
published_at: '2026-09-20T10:00:00Z',
author: { nick: 'loki', avatar_url: null },
}
test('russian plural for copies', () => {
expect(copiesLabel(1)).toBe('Скопировали 1 раз')
expect(copiesLabel(3)).toBe('Скопировали 3 раза')
expect(copiesLabel(5)).toBe('Скопировали 5 раз')
expect(copiesLabel(21)).toBe('Скопировали 21 раз')
})
test('list renders items with author and plural', async () => {
mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /showcase': () => json({ items: [listing], page: 0, has_more: false }),
})
renderApp([{ path: '/', element: <ListingList /> }])
expect(await screen.findByText('Aura / Trails')).toBeInTheDocument()
expect(screen.getByText('loki')).toBeInTheDocument()
expect(screen.getByText('Скопировали 3 раза')).toBeInTheDocument()
})
test('sort toggle updates the query and refetches', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({}, 401),
'GET /showcase': () => json({ items: [listing], page: 0, has_more: false }),
})
renderApp([{ path: '/', element: <ListingList /> }], '/?sort=new')
await screen.findByText('Aura / Trails')
await user.click(screen.getByRole('button', { name: 'Популярные' }))
expect(fetch.mock.calls.some(([u]) => String(u).includes('sort=popular'))).toBe(true)
})
test('anonymous copy shows the login prompt', async () => {
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
expect(await screen.findByText('Войди, чтобы скопировать конфиг к себе')).toBeInTheDocument()
})
test('authenticated copy disables occupied slots and posts the slot', async () => {
const user = userEvent.setup()
const fetch = mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json({ id: 'u1', email: 'a@b.c', nick: 'loki', avatar_url: null }),
'GET /configs': () => json([{ slot: 1, name: 'x', updated_at: '2026-09-20T10:00:00Z', share_code: 'A', published: false }]),
'POST /showcase/l1/copy': () => json({}, 201),
})
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
const slot1 = await screen.findByRole('button', { name: 'Слот 1' })
await waitFor(() => expect(slot1).toBeDisabled())
const slot2 = screen.getByRole('button', { name: 'Слот 2' })
await user.click(slot2)
expect(await screen.findByText('Скопировано в слот 2')).toBeInTheDocument()
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/copy'))?.[1]?.body
expect(JSON.parse(String(body))).toEqual({ slot: 2 })
})
test('409 shows the conflict message', async () => {
const user = userEvent.setup()
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json({ id: 'u1', email: 'a@b.c', nick: 'loki', avatar_url: null }),
'GET /configs': () => json([]),
'POST /showcase/l1/copy': () => json({ error: 'target slot is not empty' }, 409),
})
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
const slot1 = await screen.findByRole('button', { name: 'Слот 1' })
await user.click(slot1)
const panel = slot1.closest('div') as HTMLElement
expect(await within(panel.parentElement as HTMLElement).findByText(/Этот слот уже занят/)).toBeInTheDocument()
})

View file

@ -0,0 +1,16 @@
export type Author = { nick: string; avatar_url: string | null }
export type Listing = {
id: string
title: string
description: string
config_name: string
copies_count: number
published_at: string
author: Author | null
author_id?: string | null
}
export type ListingDetail = Listing & { data: unknown }
export type ShowcasePage = { items: Listing[]; page: number; has_more: boolean }

View file

@ -4,24 +4,32 @@ import { themeToCssVars } from '../../clickgui/themeVars'
import { VoxelScene } from '../../landing/effects/VoxelScene'
import { parseArgb, type ThemeEntry } from '../types'
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */
export function EditorPreview({ draft }: { draft: ThemeEntry }) {
type Props = {
draft: ThemeEntry
/** A theme card clicked inside the preview's Settings screen: load it into the draft, not the site. */
onPickPreset: (id: string) => void
}
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene, sized to fit its column. */
export function EditorPreview({ draft, onPickPreset }: Props) {
const vars = useMemo(() => themeToCssVars(draft), [draft])
const { r, g, b } = parseArgb(draft.theme.accent)
return (
<div className="lv-editor-preview relative min-h-[420px] overflow-hidden rounded-panel border border-white/10">
<div className="lv-editor-preview relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-panel border border-white/10">
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} className="absolute inset-0" />
<div className="relative flex h-full flex-col items-center justify-center gap-6 p-6" style={vars}>
<div className="flex w-full justify-center gap-4">
<span className="lv-hud-tile" style={{ background: 'var(--gui-card-gradient)' }}>
<div className="relative flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4" style={vars}>
<div className="flex shrink-0 justify-center gap-3">
<span className="lv-hud-tile lv-hud-tile-card" style={{ background: 'var(--gui-card-gradient)' }}>
<b>FPS</b> 240
</span>
<span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}>
<b>XYZ</b> 12 · 64 · -8
</span>
</div>
<ClickGuiWindow theme={draft} demo={false} className="w-full max-w-[520px]" />
<div className="lv-editor-stage">
<ClickGuiWindow theme={draft} demo={false} onPickTheme={onPickPreset} className="lv-editor-gui" />
</div>
</div>
</div>
)

View file

@ -1,18 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
import { EditorPreview } from './EditorPreview'
import { ColorField } from './ColorField'
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec'
import { GradientField } from './GradientField'
import { useThemeHistory } from './useThemeHistory'
import { ColorField } from './fields/ColorField'
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './model/codec'
import { GradientField } from './fields/GradientField'
import { useThemeHistory } from './model/useThemeHistory'
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */
const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
/** `intro` (page title + lead) sits on top of the controls column so the whole editor fits one screen. */
export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: ReactNode }) {
const { t } = useTranslation('themes')
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
const [notice, setNotice] = useState<string | null>(null)
@ -61,6 +62,11 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
flash(t('editor.shared'))
}
const pickPreset = (id: string) => {
const preset = PRESETS.find((p) => p.id === id)
if (preset) reset(preset)
}
const onFile = (file?: File) => {
if (!file) return
file.text().then((text) => {
@ -71,10 +77,13 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
}
return (
<div className="grid gap-8 lg:grid-cols-2">
<EditorPreview draft={draft} />
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 py-6 [grid-template-areas:'intro'_'preview'_'controls'] lg:h-[calc(100svh-var(--topbar-h))] lg:min-h-[560px] lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-x-8 lg:gap-y-4 lg:py-4 lg:[grid-template-areas:'preview_intro'_'preview_controls']">
{intro && <div className="[grid-area:intro]">{intro}</div>}
<div className="flex min-h-0 flex-col [grid-area:preview]">
<EditorPreview draft={draft} onPickPreset={pickPreset} />
</div>
<div className="flex flex-col gap-6">
<div className="flex min-h-0 flex-col gap-4 [grid-area:controls]">
<div className="flex flex-wrap items-center gap-2">
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
{t('editor.undo')}
@ -88,6 +97,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
</div>
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-6 lg:-mr-3 lg:overflow-y-auto lg:pr-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-frost">{t('editor.nameLabel')}</span>
<input
@ -115,7 +125,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
<section className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{COLOR_SLOTS.map((slot) => (
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
))}
@ -136,7 +146,9 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
</div>
</section>
<div className="flex flex-wrap gap-2">
</div>
<div className="grid grid-cols-2 gap-2 border-t border-white/10 pt-4 sm:grid-cols-4 lg:grid-cols-2">
<Button onClick={copy}>{t('editor.copy')}</Button>
<Button variant="quiet" onClick={download}>
{t('editor.download')}

View file

@ -1,6 +1,15 @@
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
/* On desktop the editor is one viewport tall: the stage is a size container and the ClickGui
(narrow layout, 264:330) takes the largest width that still fits its height. */
.lv-editor-gui { margin-inline: auto; }
@media (min-width: 1024px) {
.lv-editor-stage { flex: 1; min-height: 0; container-type: size; display: grid; place-items: center; }
.lv-editor-gui { width: min(100cqw, 100cqh * 264 / 330); }
}
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: rgb(255 255 255 / 0.18) transparent; }
.lv-hud-tile {
display: inline-flex;
align-items: baseline;
@ -14,6 +23,7 @@
box-shadow: 0 6px 20px -12px #000;
}
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
.lv-hud-tile-card b { color: var(--gui-text, #fff); }
.lv-preset-chip {
padding: 4px 12px;

View file

@ -1,5 +1,5 @@
import { useId, useState } from 'react'
import { parseArgb } from '../types'
import { argbToCss, parseArgb } from '../../types'
type Props = { label: string; value: string; onChange: (hex: string) => void }
@ -28,12 +28,10 @@ export function ColorField({ label, value, onChange }: Props) {
}
return (
<label htmlFor={id} className="lv-color-field flex items-center gap-2">
<span
className="lv-alpha-check size-8 shrink-0 rounded-md border border-white/15"
style={{ backgroundImage: `linear-gradient(45deg, ${value}, ${value})` }}
aria-hidden="true"
/>
<label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2">
<span className="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15" aria-hidden="true">
<span className="block size-full" style={{ background: argbToCss(value) }} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-xs text-frost">{label}</span>
<span className="flex min-w-0 items-center gap-2">

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import type { GradientSpec } from '../types'
import type { GradientSpec } from '../../types'
import { ColorField } from './ColorField'
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
@ -9,7 +9,7 @@ export function GradientField({ title, value, onChange }: Props) {
const { t } = useTranslation('themes')
return (
<div className="lv-gradient-field flex flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
<div className="lv-gradient-field flex min-w-0 flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
<label className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-snow">{title}</span>
<span className="flex items-center gap-2 text-xs text-frost">

View file

@ -1,5 +1,5 @@
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
import { PRESETS } from '../../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as
// `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`.

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import type { ThemeEntry } from '../types'
import type { ThemeEntry } from '../../types'
const LIMIT = 50
/** Changes closer than this share one undo step, so a slider drag is a single revert. */

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec'
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../model/codec'
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
const PROFILE_KEYS = [

View file

@ -1,6 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { ActiveThemeProvider, useActiveTheme } from '../../useActiveTheme'
import { ThemeEditor } from '../ThemeEditor'
test('editor shows the initial preset name and one toggle per gradient slot', () => {
@ -16,3 +18,24 @@ test('picking a preset chip loads it into the editor', () => {
fireEvent.click(screen.getByRole('button', { name: next.name }))
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
})
function SiteTheme() {
return <output aria-label="site theme">{useActiveTheme().theme.id}</output>
}
test('a theme card inside the preview loads that preset into the draft, not the site', async () => {
const user = userEvent.setup()
render(
<ActiveThemeProvider applyToPage={false}>
<SiteTheme />
<ThemeEditor initial={PRESETS[0]} />
</ActiveThemeProvider>,
)
const gui = screen.getByTestId('clickgui-root')
await user.click(within(gui).getByRole('tab', { name: 'Settings' }))
await user.click(within(gui).getByRole('option', { name: 'Тема Nitro' }))
expect(screen.getByDisplayValue('Nitro')).toBeInTheDocument()
expect(within(gui).getByRole('option', { name: 'Тема Nitro' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('status', { name: 'site theme' })).toHaveTextContent(PRESETS[0].id)
expect(window.localStorage.getItem('lv_theme')).toBeNull()
})

View file

@ -44,6 +44,6 @@ export const themesEn = {
reset: 'Reset',
undo: 'Undo',
redo: 'Redo',
importError: 'Could not read the theme — the file is malformed.',
importError: 'Could not read the theme: the file is malformed.',
},
} satisfies Translation<typeof themesRu>

View file

@ -41,6 +41,6 @@ export const themesRu = {
reset: 'Сбросить',
undo: 'Отменить',
redo: 'Вернуть',
importError: 'Не удалось прочитать тему — файл испорчен.',
importError: 'Не удалось прочитать тему: файл испорчен.',
},
} as const

View file

@ -36,6 +36,9 @@
@layer base {
html {
/* Height reserved for the floating top bar (TopBar): its top margin plus the bar itself. */
--topbar-h: 4.25rem;
scroll-padding-top: calc(var(--topbar-h) + 1rem);
color-scheme: dark;
transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
}
@ -64,3 +67,7 @@
transition-duration: 0.01ms !important;
}
}
/* Konami easter egg: the whole page cycles through the hue wheel for a few seconds. */
html.lv-konami { animation: lv-konami-hue 1.4s linear infinite; }
@keyframes lv-konami-hue { to { filter: hue-rotate(360deg); } }

View file

@ -2,9 +2,11 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { initI18n } from './app/i18n'
import { printConsoleGreeting } from './shared/easter/consoleGreeting'
import App from './app/App.tsx'
initI18n()
printConsoleGreeting(navigator.language)
createRoot(document.getElementById('root')!).render(
<StrictMode>

View file

@ -0,0 +1,11 @@
import { DeviceList } from '../../features/account/components/DeviceList'
import { ProfileCard } from '../../features/account/components/ProfileCard'
export default function AccountPage() {
return (
<section className="grid grid-cols-1 gap-6 py-12 min-[900px]:grid-cols-[320px_1fr]">
<ProfileCard />
<DeviceList />
</section>
)
}

View file

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { SlotGrid } from '../../features/configs/components/SlotGrid'
const ACCENT = 'rgb(92, 200, 231)'
export default function ConfigsPage() {
const { t } = useTranslation('configs')
return (
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
<VoxelScene accent={ACCENT} />
</div>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
<div>
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
<p className="mt-2 max-w-[60ch] text-frost">
{t('page.leadBefore')} <code className="font-code text-ice">%config save &lt;слот&gt;</code>
{t('page.leadAfter')}
</p>
</div>
<SlotGrid />
</div>
</section>
)
}

View file

@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next'
import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
const STEPS = ['one', 'two', 'three'] as const
export default function LinkPage() {
const { t } = useTranslation('deviceLink')
return (
<section className="flex flex-col gap-8 py-12">
<div>
<h1 className="text-3xl">{t('page.title')}</h1>
<p className="mt-2 max-w-sm text-frost">{t('page.lead')}</p>
</div>
<ol className="flex flex-col gap-3 sm:flex-row sm:gap-4">
{STEPS.map((step, i) => (
<li
key={step}
className="flex flex-1 items-start gap-3 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-3"
>
<span
aria-hidden="true"
className="flex h-6 w-6 flex-none items-center justify-center rounded-full bg-ice font-code text-sm font-bold text-abyss"
>
{i + 1}
</span>
<p className="text-sm text-frost">{t(`steps.${step}`)}</p>
</li>
))}
</ol>
<LinkDeviceForm />
</section>
)
}

View file

@ -1,23 +1,26 @@
import { CommandHero } from '../../features/landing/CommandHero'
import { HowItWorks } from '../../features/landing/HowItWorks'
import { FaqDownload } from '../../features/landing/sections/FaqDownload'
import { Hero } from '../../features/landing/sections/Hero'
import { HudPlayground } from '../../features/landing/sections/HudPlayground'
import { ModuleWall } from '../../features/landing/sections/ModuleWall'
import { ShowcaseTeaser } from '../../features/landing/sections/ShowcaseTeaser'
import { ThemeStrip } from '../../features/landing/sections/ThemeStrip'
import { FaqDownload } from '../../features/landing/sections/explore/FaqDownload'
import { Hero } from '../../features/landing/sections/hero/Hero'
import { HudPlayground } from '../../features/landing/sections/explore/HudPlayground'
import { ModuleWall } from '../../features/landing/sections/explore/ModuleWall'
import { ShowcaseTeaser } from '../../features/landing/sections/explore/ShowcaseTeaser'
import { ThemeStrip } from '../../features/landing/sections/hero/ThemeStrip'
export default function HomePage() {
return (
<>
<Hero />
<ThemeStrip />
<HudPlayground />
<CommandHero />
<HowItWorks />
<ModuleWall />
<ShowcaseTeaser />
<FaqDownload />
<div className="lv-lower">
<div aria-hidden="true" className="lv-lower-bg" />
<HudPlayground />
<CommandHero />
<HowItWorks />
<ModuleWall />
<ShowcaseTeaser />
<FaqDownload />
</div>
</>
)
}

View file

@ -1,12 +1,12 @@
import { useTranslation } from 'react-i18next'
import { LoginForm } from '../../features/auth/forms/LoginForm'
import { AuthSplit } from './auth/AuthSplit'
export default function LoginPage() {
const { t } = useTranslation('auth')
return (
<section className="flex flex-col gap-6 py-12">
<h1 className="text-3xl">{t('login.title')}</h1>
<AuthSplit title={t('login.title')}>
<LoginForm />
</section>
</AuthSplit>
)
}

View file

@ -1,10 +1,15 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
export default function NotFoundPage() {
const { t } = useTranslation('common')
return (
<section className="flex flex-col gap-4 py-16">
<pre aria-hidden="true" className="w-fit max-w-full overflow-x-auto rounded-panel border border-white/10 bg-black/30 p-4 font-code text-xs leading-tight text-ice">
{NOT_FOUND_ART}
{t('notFound.console')}
</pre>
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1>
<p className="text-frost">
{t('notFound.body')}{' '}

View file

@ -0,0 +1,11 @@
import { useParams } from 'react-router'
import { PublicProfileView } from '../../features/profile/components/PublicProfileView'
export default function ProfilePage() {
const { id = '' } = useParams()
return (
<section className="py-12">
<PublicProfileView id={id} />
</section>
)
}

View file

@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next'
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
import { AuthSplit } from './auth/AuthSplit'
export default function RegisterPage() {
const { t } = useTranslation('auth')
return (
<section className="flex flex-col gap-6 py-12">
<h1 className="text-3xl">{t('register.title')}</h1>
<p className="max-w-sm text-frost">{t('register.subtitle')}</p>
<AuthSplit title={t('register.title')}>
<p className="mb-4 max-w-sm text-frost">{t('register.subtitle')}</p>
<RegisterForm />
</section>
</AuthSplit>
)
}

View file

@ -0,0 +1,36 @@
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 (
<div className="flex min-h-[calc(100vh-var(--topbar-h)-4rem)] items-center justify-center py-10 min-[900px]:justify-between min-[900px]:gap-12">
<div className="w-full max-w-md rounded-(--radius-panel) border border-shoal bg-slate/80 shadow-[0_0_60px_-20px_var(--page-accent-glow,transparent)] backdrop-blur">
<div className="flex items-center gap-2 border-b border-shoal px-5 py-4">
<span aria-hidden="true" className="h-2.5 w-2.5 rounded-full bg-ice" />
<h1 className="font-display text-xl">{title}</h1>
</div>
<div className="px-5 py-6">{children}</div>
</div>
<div className="relative hidden min-[900px]:flex min-[900px]:h-[520px] min-[900px]:w-[440px] min-[900px]:shrink-0 min-[900px]:items-end min-[900px]:overflow-hidden min-[900px]:rounded-(--radius-panel) min-[900px]:border min-[900px]:border-shoal">
<VoxelScene accent={particleColor} />
<ParticleField color={particleColor} className="mix-blend-screen" />
<div className="relative z-10 w-full p-5">
<ClickGuiWindow demo className="w-full max-w-[300px] drop-shadow-2xl" />
<p className="mt-4 max-w-[300px] font-display text-sm text-snow drop-shadow">{t('splitPitch')}</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,21 @@
import { useParams } from 'react-router'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { ListingDetail } from '../../features/showcase/components/ListingDetail'
const ACCENT = 'rgb(92, 200, 231)'
export default function ListingPage() {
const { id } = useParams<{ id: string }>()
return (
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
<VoxelScene accent={ACCENT} />
</div>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto w-full max-w-[720px] px-4 pt-12 sm:px-6">{id ? <ListingDetail id={id} /> : null}</div>
</section>
)
}

View file

@ -0,0 +1,11 @@
import { lazy, Suspense } from 'react'
const ListingPage = lazy(() => import('./ListingPage'))
export default function ListingRoute() {
return (
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
<ListingPage />
</Suspense>
)
}

View file

@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { ListingList } from '../../features/showcase/components/ListingList'
const ACCENT = 'rgb(92, 200, 231)'
export default function ShowcasePage() {
const { t } = useTranslation('showcase')
return (
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
<VoxelScene accent={ACCENT} />
</div>
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
<ParticleField color={ACCENT} className="opacity-50" />
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
<div>
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
<p className="mt-2 max-w-[60ch] text-frost">{t('page.lead')}</p>
</div>
<ListingList />
</div>
</section>
)
}

View file

@ -0,0 +1,12 @@
import { lazy, Suspense } from 'react'
// Loaded on demand: the showcase pages pull in extra query/routing glue not needed on the landing.
const ShowcasePage = lazy(() => import('./ShowcasePage'))
export default function ShowcaseRoute() {
return (
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
<ShowcasePage />
</Suspense>
)
}

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
import { fromShareHash } from '../../features/themes/editor/codec'
import { fromShareHash } from '../../features/themes/editor/model/codec'
import { PRESETS } from '../../features/themes/presets.generated'
import '../../features/themes/editor/editor.css'
@ -19,12 +19,14 @@ export default function ThemesPage() {
}, [])
return (
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-6 py-12">
<header className="flex flex-col gap-2">
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('editor.title')}</h1>
<p className="text-frost">{t('editor.lead')}</p>
</header>
<ThemeEditor initial={initial} />
</div>
<ThemeEditor
initial={initial}
intro={
<header className="flex flex-col gap-1">
<h1 className="font-display text-3xl font-black uppercase text-snow">{t('editor.title')}</h1>
<p className="text-sm text-frost">{t('editor.lead')}</p>
</header>
}
/>
)
}

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

Some files were not shown because too many files have changed in this diff Show more