chore(history): squash 67 commit(s) from 2026-09-25
- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints - feat(frontend): app shell, routing and landing page with the chat-command hero - feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion - docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans - feat(accounts): internal gRPC AuthenticateDevice guarded by internal key - feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch - feat(accounts): GET /me profile endpoint - feat(gateway): scaffold crate with config validation and health check - feat(gateway): reverse proxy to accounts and configs services - feat(gateway): resolve identity once from access JWT or device token via gRPC - feat(gateway): per-route and global rate limits with Retry-After - feat(gateway): CORS for the site origin; docs for gateway and internal contract - feat(configs): scaffold service with schema, config validation and health check - feat(configs): four config slots per account with list, get and save - feat(configs): permanent share codes with regenerate and public load-by-code - feat(accounts): GetPublicProfiles gRPC for showcase author info - style(accounts,common): apply rustfmt to existing sources - feat(configs): public showcase with publish, browse, detail and copy-to-slot - feat(backend): public profile endpoint and showcase author filter - fix(gateway): silence clippy collapsible-if and needless-ref warnings - docs(backend): configs-service implemented; Подсистема 1 backend complete - feat(mod): add Optimize module skeleton with OptimizeState holder - feat(mod): gate glass blur behind Optimize no_glass knob - feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob - feat(mod): trim procedural sky noise behind lite_sky knob - feat(mod): drop fade gradients and digit rolls behind lean_hud knob - docs(todo): mark Optimize module phase 9.2 complete - refactor(mod): drop dead Renderer2D compatibility shims - refactor(mod): prune unreachable Renderer2D overload towers - refactor(mod): remove unused Renderer2D overloads and imports - docs(todo): mark Renderer2D giant-splitting done (2179 to 1597) - refactor(mod): extract shader id constants from LoVisualRenderPipelines - docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines) - refactor(mod): move Renderer2D instance state into base class - refactor(mod): extract Renderer2DRounded drawing family - refactor(mod): extract Renderer2DPath connector and chamfer family - refactor(mod): extract Renderer2DShapes circle line and texture primitives - refactor(mod): extract Renderer2DGlass and Renderer2DItem families - refactor(mod): prune Renderer2D imports after facade split - docs(todo): record Renderer2D facade inheritance split (1597 to 475) - docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot - feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer) - refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants) - refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec - feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution - feat(mod): flat fallback for no-glass optimize mode and persist global HUD config - feat(mod): default HUD bg effects to Auto so the global surface style drives widgets - feat(mod): add global cycle-style hotkey with surface style notification - feat(mod): add surface style swatch strip under the global style picker - feat(gateway): reject ambiguous paths and answer .env probes with a honeypot - fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth - feat(frontend): ClickGui theme pipeline generated from the mod, live site theming - feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip - docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9 - feat(gateway): answer /coffee with a 418 teapot - feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer - feat(frontend): one-click download from GitHub releases, changelog page, release CI - feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links - fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping) - style(frontend): apply ClickGui glass effect to landing HUD playground widgets - fix(frontend): prevent color field row overflow in theme editor grid - fix(frontend): never attach stale bearer token to /auth/* requests - fix(configs): unpublish/publish can no longer bypass moderation - refactor(accounts): shrink auth/handlers.rs under the 250-line cap - fix(accounts): tolerate concurrent refresh without killing every session - fix(gateway): minor hardening from the backend review - feat(configs): IDDQD easter egg config
This commit is contained in:
parent
72bc4c7148
commit
7f4b532f99
257 changed files with 13085 additions and 6582 deletions
|
|
@ -1,7 +0,0 @@
|
|||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
|
||||
const router = createBrowserRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
20
frontend/src/app/App.tsx
Normal file
20
frontend/src/app/App.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
import { SessionProvider } from '../features/auth/session'
|
||||
import { ActiveThemeProvider } from '../features/themes/useActiveTheme'
|
||||
import { routes } from './routes'
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 } } })
|
||||
const router = createBrowserRouter(routes)
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<ActiveThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ActiveThemeProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
43
frontend/src/app/i18n.ts
Normal file
43
frontend/src/app/i18n.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import i18next, { type i18n } from 'i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { authEn } from '../features/auth/i18n/en'
|
||||
import { authRu } from '../features/auth/i18n/ru'
|
||||
import { clickguiEn } from '../features/clickgui/i18n/en'
|
||||
import { clickguiRu } from '../features/clickgui/i18n/ru'
|
||||
import { downloadEn } from '../features/download/i18n/en'
|
||||
import { downloadRu } from '../features/download/i18n/ru'
|
||||
import { landingEn } from '../features/landing/i18n/en'
|
||||
import { landingRu } from '../features/landing/i18n/ru'
|
||||
import { themesEn } from '../features/themes/i18n/en'
|
||||
import { themesRu } from '../features/themes/i18n/ru'
|
||||
import { commonEn } from '../shared/i18n/common.en'
|
||||
import { commonRu } from '../shared/i18n/common.ru'
|
||||
|
||||
/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */
|
||||
export function initI18n(lng?: 'ru' | 'en'): i18n {
|
||||
const instance = i18next.createInstance()
|
||||
instance
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.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 },
|
||||
},
|
||||
fallbackLng: 'ru',
|
||||
supportedLngs: ['ru', 'en'],
|
||||
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'],
|
||||
defaultNS: 'common',
|
||||
interpolation: { escapeValue: false },
|
||||
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },
|
||||
})
|
||||
|
||||
document.documentElement.lang = instance.language
|
||||
instance.on('languageChanged', (next) => {
|
||||
document.documentElement.lang = next
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
21
frontend/src/app/i18next.d.ts
vendored
Normal file
21
frontend/src/app/i18next.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { authRu } from '../features/auth/i18n/ru'
|
||||
import type { clickguiRu } from '../features/clickgui/i18n/ru'
|
||||
import type { downloadRu } from '../features/download/i18n/ru'
|
||||
import type { landingRu } from '../features/landing/i18n/ru'
|
||||
import type { themesRu } from '../features/themes/i18n/ru'
|
||||
import type { commonRu } from '../shared/i18n/common.ru'
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common'
|
||||
resources: {
|
||||
common: typeof commonRu
|
||||
auth: typeof authRu
|
||||
landing: typeof landingRu
|
||||
themes: typeof themesRu
|
||||
clickgui: typeof clickguiRu
|
||||
download: typeof downloadRu
|
||||
// Later tasks extend this with their own feature namespaces.
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/routes.tsx
Normal file
25
frontend/src/app/routes.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { RouteObject } from 'react-router'
|
||||
import { RequireAuth } from '../features/auth/RequireAuth'
|
||||
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 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 ThemesRoute from '../pages/themes/ThemesRoute'
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/register', element: <RegisterPage /> },
|
||||
{ path: '/download', element: <DownloadRoute /> },
|
||||
{ path: '/themes', element: <ThemesRoute /> },
|
||||
{ element: <RequireAuth />, children: [] }, // Tasks 6–8 add /link, /account, /configs
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
import { useSession } from './session'
|
||||
|
||||
export function RequireAuth() {
|
||||
const { t } = useTranslation('auth')
|
||||
const { status } = useSession()
|
||||
const location = useLocation()
|
||||
if (status === 'loading') return <p className="text-frost">Проверяем вход…</p>
|
||||
if (status === 'loading') return <p className="text-frost">{t('checkingSession')}</p>
|
||||
if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
return <Outlet />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
|
|
@ -7,6 +8,8 @@ import { TextField } from '../../../shared/ui/TextField'
|
|||
import { useSession } from '../session'
|
||||
|
||||
export function LoginForm() {
|
||||
const { t } = useTranslation('auth')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const { signIn } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
|
||||
|
|
@ -23,7 +26,7 @@ export function LoginForm() {
|
|||
await signIn(email.trim(), password)
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 401: 'Неверная почта или пароль.' }))
|
||||
setError(describeError(tc, err, { 401: t('login.wrongCredentials') }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -31,9 +34,15 @@ export function LoginForm() {
|
|||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField label="Почта" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<TextField
|
||||
label="Пароль"
|
||||
label={t('login.emailLabel')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t('login.passwordLabel')}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
|
|
@ -41,10 +50,13 @@ export function LoginForm() {
|
|||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Войти
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Нет аккаунта? <Link to="/register" className="text-ice underline-offset-4 hover:underline">Создать</Link>
|
||||
{t('login.noAccount')}{' '}
|
||||
<Link to="/register" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('login.createAccount')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
|
|
@ -8,6 +9,8 @@ import { useSession } from '../session'
|
|||
import { validateEmail, validateNick, validatePassword } from '../validation'
|
||||
|
||||
export function RegisterForm() {
|
||||
const { t } = useTranslation('auth')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const { signUp } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
|
|
@ -23,9 +26,9 @@ export function RegisterForm() {
|
|||
e.preventDefault()
|
||||
setError(undefined)
|
||||
|
||||
const emailErr = validateEmail(email)
|
||||
const nickErr = validateNick(nick)
|
||||
const passwordErr = validatePassword(password)
|
||||
const emailErr = validateEmail(email, t)
|
||||
const nickErr = validateNick(nick, t)
|
||||
const passwordErr = validatePassword(password, t)
|
||||
setEmailError(emailErr)
|
||||
setNickError(nickErr)
|
||||
setPasswordError(passwordErr)
|
||||
|
|
@ -36,7 +39,7 @@ export function RegisterForm() {
|
|||
await signUp(email.trim(), password, nick.trim())
|
||||
navigate('/configs', { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 409: 'Эта почта уже занята. Войди или используй другую.' }))
|
||||
setError(describeError(tc, err, { 409: t('register.emailTaken') }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -45,7 +48,7 @@ export function RegisterForm() {
|
|||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField
|
||||
label="Почта"
|
||||
label={t('register.emailLabel')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
|
|
@ -53,28 +56,31 @@ export function RegisterForm() {
|
|||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Ник на сайте"
|
||||
label={t('register.nickLabel')}
|
||||
autoComplete="nickname"
|
||||
value={nick}
|
||||
error={nickError}
|
||||
hint={nickError ? undefined : 'Не обязательно совпадает с ником в Minecraft.'}
|
||||
hint={nickError ? undefined : t('register.nickHint')}
|
||||
onChange={(e) => setNick(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
label={t('register.passwordLabel')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
error={passwordError}
|
||||
hint={passwordError ? undefined : 'Минимум 8 символов.'}
|
||||
hint={passwordError ? undefined : t('register.passwordHint')}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Создать аккаунт
|
||||
{t('register.submit')}
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Уже есть аккаунт? <Link to="/login" className="text-ice underline-offset-4 hover:underline">Войти</Link>
|
||||
{t('register.haveAccount')}{' '}
|
||||
<Link to="/login" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('register.login')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
36
frontend/src/features/auth/i18n/en.ts
Normal file
36
frontend/src/features/auth/i18n/en.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { authRu } from './ru'
|
||||
|
||||
export const authEn = {
|
||||
checkingSession: 'Checking session…',
|
||||
login: {
|
||||
title: 'Log in',
|
||||
emailLabel: 'Email',
|
||||
passwordLabel: 'Password',
|
||||
submit: 'Log in',
|
||||
noAccount: "Don't have an account?",
|
||||
createAccount: 'Sign up',
|
||||
wrongCredentials: 'Wrong email or password.',
|
||||
},
|
||||
register: {
|
||||
title: 'New account',
|
||||
subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.",
|
||||
emailLabel: 'Email',
|
||||
nickLabel: 'Site nickname',
|
||||
nickHint: "Doesn't have to match your Minecraft nick.",
|
||||
passwordLabel: 'Password',
|
||||
passwordHint: 'At least 8 characters.',
|
||||
submit: 'Create account',
|
||||
haveAccount: 'Already have an account?',
|
||||
login: 'Log in',
|
||||
emailTaken: 'That email is already taken. Log in or use another one.',
|
||||
},
|
||||
validation: {
|
||||
emailRequired: 'Enter your email.',
|
||||
emailInvalid: 'That email looks off.',
|
||||
passwordShort: 'At least 8 characters.',
|
||||
passwordTooLong: 'Password is too long.',
|
||||
nickRequired: 'Pick a nickname.',
|
||||
nickTooLong: 'No longer than 32 characters.',
|
||||
},
|
||||
} satisfies Translation<typeof authRu>
|
||||
33
frontend/src/features/auth/i18n/ru.ts
Normal file
33
frontend/src/features/auth/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const authRu = {
|
||||
checkingSession: 'Проверяем вход…',
|
||||
login: {
|
||||
title: 'Вход',
|
||||
emailLabel: 'Почта',
|
||||
passwordLabel: 'Пароль',
|
||||
submit: 'Войти',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
createAccount: 'Создать',
|
||||
wrongCredentials: 'Неверная почта или пароль.',
|
||||
},
|
||||
register: {
|
||||
title: 'Новый аккаунт',
|
||||
subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.',
|
||||
emailLabel: 'Почта',
|
||||
nickLabel: 'Ник на сайте',
|
||||
nickHint: 'Не обязательно совпадает с ником в Minecraft.',
|
||||
passwordLabel: 'Пароль',
|
||||
passwordHint: 'Минимум 8 символов.',
|
||||
submit: 'Создать аккаунт',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
login: 'Войти',
|
||||
emailTaken: 'Эта почта уже занята. Войди или используй другую.',
|
||||
},
|
||||
validation: {
|
||||
emailRequired: 'Введи почту.',
|
||||
emailInvalid: 'Похоже, в почте опечатка.',
|
||||
passwordShort: 'Минимум 8 символов.',
|
||||
passwordTooLong: 'Слишком длинный пароль.',
|
||||
nickRequired: 'Придумай ник.',
|
||||
nickTooLong: 'Не длиннее 32 символов.',
|
||||
},
|
||||
} as const
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
export function validateEmail(email: string): string | undefined {
|
||||
import type { TFunction } from 'i18next'
|
||||
|
||||
export function validateEmail(email: string, t: TFunction<'auth'>): string | undefined {
|
||||
const v = email.trim()
|
||||
if (!v) return 'Введи почту.'
|
||||
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return 'Похоже, в почте опечатка.'
|
||||
if (!v) return t('validation.emailRequired')
|
||||
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return t('validation.emailInvalid')
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): string | undefined {
|
||||
if ([...password].length < 8) return 'Минимум 8 символов.'
|
||||
if (new TextEncoder().encode(password).length > 256) return 'Слишком длинный пароль.'
|
||||
export function validatePassword(password: string, t: TFunction<'auth'>): string | undefined {
|
||||
if ([...password].length < 8) return t('validation.passwordShort')
|
||||
if (new TextEncoder().encode(password).length > 256) return t('validation.passwordTooLong')
|
||||
}
|
||||
|
||||
export function validateNick(nick: string): string | undefined {
|
||||
export function validateNick(nick: string, t: TFunction<'auth'>): string | undefined {
|
||||
const len = [...nick.trim()].length
|
||||
if (len === 0) return 'Придумай ник.'
|
||||
if (len > 32) return 'Не длиннее 32 символов.'
|
||||
if (len === 0) return t('validation.nickRequired')
|
||||
if (len > 32) return t('validation.nickTooLong')
|
||||
}
|
||||
|
|
|
|||
130
frontend/src/features/clickgui/ClickGuiWindow.tsx
Normal file
130
frontend/src/features/clickgui/ClickGuiWindow.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { type CSSProperties, 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 { themeToCssVars } from './themeVars'
|
||||
|
||||
type Props = {
|
||||
/** Runs the scripted cursor demo (pauses while hovered or when `paused`). */
|
||||
demo?: boolean
|
||||
paused?: boolean
|
||||
/** Overrides the active theme — for previews. */
|
||||
theme?: ThemeEntry
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ClickGuiWindow({ demo = true, paused = false, theme: override, 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 themeIdRef = useRef(theme.id)
|
||||
useEffect(() => {
|
||||
themeIdRef.current = theme.id
|
||||
}, [theme.id])
|
||||
|
||||
const { cursor, state, dispatch } = useDemo(DEMO, {
|
||||
paused: !demo || paused || hovered,
|
||||
reduced,
|
||||
rootRef,
|
||||
nextTheme: () => {
|
||||
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'),
|
||||
})
|
||||
|
||||
const vars = useMemo(() => themeToCssVars(theme), [theme])
|
||||
const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
role="group"
|
||||
aria-label={t('windowLabel')}
|
||||
data-testid="clickgui-root"
|
||||
className={`lv-gui ${className}`}
|
||||
style={vars}
|
||||
onPointerEnter={(e) => e.pointerType === 'mouse' && 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-body">
|
||||
<aside className="lv-gui-cats" aria-label={t('categories')}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.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 })}
|
||||
>
|
||||
<CategoryIcon id={c.id} />
|
||||
<span className="lv-gui-cat-name">{c.name}</span>
|
||||
<span className="lv-gui-cat-count">{c.modules.length}</span>
|
||||
</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>
|
||||
</div>
|
||||
<DemoCursor cursor={cursor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/features/clickgui/ModuleCard.tsx
Normal file
59
frontend/src/features/clickgui/ModuleCard.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
175
frontend/src/features/clickgui/demo.ts
Normal file
175
frontend/src/features/clickgui/demo.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { CATEGORIES, type CategoryId, settingKey } from './parts/modules'
|
||||
|
||||
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }
|
||||
|
||||
export const LOOP_MS = 12_500
|
||||
|
||||
/** The scripted loop: pick Visuals, switch Trails on, open it, drag Length, tick Rainbow, change theme, tidy up. */
|
||||
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' },
|
||||
]
|
||||
|
||||
export type GuiState = {
|
||||
category: CategoryId
|
||||
enabled: Record<string, boolean>
|
||||
expanded: string | null
|
||||
values: Record<string, number | boolean | string>
|
||||
dragging: boolean
|
||||
}
|
||||
|
||||
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 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 }
|
||||
// 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 }
|
||||
}
|
||||
|
||||
export function guiReducer(state: GuiState, action: GuiAction): GuiState {
|
||||
switch (action.type) {
|
||||
case 'category':
|
||||
return { ...state, category: action.id, expanded: null }
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
export type Cursor = { x: number; y: number; visible: boolean; pressed: boolean; duration: number }
|
||||
|
||||
type Options = {
|
||||
paused: boolean
|
||||
reduced: boolean
|
||||
rootRef: RefObject<HTMLElement | null>
|
||||
/** Resolves `theme:next` to a concrete `theme:<id>` target and applies it. */
|
||||
nextTheme: () => string
|
||||
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
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } {
|
||||
const [state, dispatch] = useReducer(guiReducer, opts.reduced, initialGuiState)
|
||||
const [cursor, setCursor] = useState<Cursor>({ x: 0, y: 0, visible: false, pressed: false, duration: 0 })
|
||||
const [cycle, setCycle] = useState(0)
|
||||
const optsRef = useRef(opts)
|
||||
useEffect(() => {
|
||||
optsRef.current = opts
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (opts.paused || opts.reduced) return
|
||||
const timers: number[] = []
|
||||
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
|
||||
let pendingTheme = ''
|
||||
|
||||
const locate = (target: string, fraction?: number) => {
|
||||
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 }
|
||||
}
|
||||
|
||||
const move = (target: string, duration: number, fraction?: number) => {
|
||||
const p = locate(target, fraction)
|
||||
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration }))
|
||||
}
|
||||
|
||||
const click = (fn: () => void) => {
|
||||
setCursor((c) => ({ ...c, pressed: true }))
|
||||
later(160, () => {
|
||||
setCursor((c) => ({ ...c, pressed: false }))
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
||||
const run = (step: DemoStep) => {
|
||||
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:', '')))
|
||||
}
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
dispatch({ type: 'reset' })
|
||||
const lead = cycle === 0 ? 600 : 0
|
||||
for (const step of steps) later(step.at + lead, () => run(step))
|
||||
later(LOOP_MS + lead, () => setCycle((n) => n + 1))
|
||||
return () => timers.forEach((t) => window.clearTimeout(t))
|
||||
}, [steps, opts.paused, opts.reduced, cycle])
|
||||
|
||||
const idle = opts.paused || opts.reduced
|
||||
return { cursor: idle ? { ...cursor, visible: false, pressed: false } : cursor, state, dispatch }
|
||||
}
|
||||
49
frontend/src/features/clickgui/i18n/en.ts
Normal file
49
frontend/src/features/clickgui/i18n/en.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
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',
|
||||
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',
|
||||
},
|
||||
} satisfies Translation<typeof clickguiRu>
|
||||
46
frontend/src/features/clickgui/i18n/ru.ts
Normal file
46
frontend/src/features/clickgui/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export const clickguiRu = {
|
||||
windowLabel: 'Интерактивная копия ClickGui LoVisual',
|
||||
bind: 'Бинд',
|
||||
bindNone: 'нет',
|
||||
toggle: 'Включить {{name}}',
|
||||
settings: 'Настройки {{name}}',
|
||||
categories: 'Категории',
|
||||
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: 'Мелкие улучшения интерфейса',
|
||||
},
|
||||
} as const
|
||||
70
frontend/src/features/clickgui/parts/Glyphs.tsx
Normal file
70
frontend/src/features/clickgui/parts/Glyphs.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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',
|
||||
}
|
||||
|
||||
export function CategoryIcon({ id }: { id: CategoryId }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** The demo's fake mouse pointer; positioned in the window's own (untransformed) coordinates. */
|
||||
export function DemoCursor({ cursor }: { cursor: Cursor }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="lv-gui-cursor"
|
||||
data-visible={cursor.visible}
|
||||
data-pressed={cursor.pressed}
|
||||
style={{
|
||||
transform: `translate3d(${cursor.x}px, ${cursor.y}px, 0)`,
|
||||
transitionDuration: `${cursor.duration}ms, 200ms`,
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
frontend/src/features/clickgui/parts/SettingControls.tsx
Normal file
92
frontend/src/features/clickgui/parts/SettingControls.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
220
frontend/src/features/clickgui/parts/clickgui.css
Normal file
220
frontend/src/features/clickgui/parts/clickgui.css
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/* ClickGui replica. Colors come from themeToCssVars(); the typed @property registrations
|
||||
let 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-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-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; }
|
||||
|
||||
.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-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-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;
|
||||
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);
|
||||
}
|
||||
|
||||
.lv-gui-header {
|
||||
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);
|
||||
}
|
||||
.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-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);
|
||||
}
|
||||
|
||||
.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-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); }
|
||||
|
||||
.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-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-cursor {
|
||||
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; opacity: 0;
|
||||
transition-property: transform, opacity; transition-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
|
||||
filter: drop-shadow(0 3px 5px rgb(0 0 0 / 0.5));
|
||||
}
|
||||
.lv-gui-cursor[data-visible="true"] { opacity: 1; }
|
||||
.lv-gui-cursor svg { position: relative; margin: -2px 0 0 -2px; transition: transform 120ms; }
|
||||
.lv-gui-cursor[data-pressed="true"] svg { transform: scale(0.86); }
|
||||
.lv-gui-cursor-ring {
|
||||
position: absolute; left: -14px; top: -14px; width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid var(--gui-accent); opacity: 0; transform: scale(0.4); transition: opacity 300ms, transform 300ms;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
97
frontend/src/features/clickgui/parts/modules.ts
Normal file
97
frontend/src/features/clickgui/parts/modules.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// 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.
|
||||
|
||||
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 GuiModule = { name: ModuleName; on?: boolean; settings?: Setting[] }
|
||||
|
||||
export type CategoryId = 'combat' | 'visuals' | 'player' | 'misc'
|
||||
|
||||
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 })
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** The ClickGui's top tab row — in-game labels. */
|
||||
export const TABS = ['Modules', 'Themes', 'Configs', 'HUD'] as const
|
||||
|
||||
/** Presets offered as quick dots in the category column (and cycled by the demo). */
|
||||
export const QUICK_THEMES = ['classic', 'nitro', 'sunset', 'aurora'] 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)
|
||||
47
frontend/src/features/clickgui/tests/clickgui.test.tsx
Normal file
47
frontend/src/features/clickgui/tests/clickgui.test.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
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>)
|
||||
|
||||
test('reduced motion shows the finished demo frame with no cursor', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
test('cards are interactive: switch categories, toggle and expand', 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')
|
||||
})
|
||||
|
||||
test('the scripted demo toggles Trails and loops back to the start', () => {
|
||||
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')
|
||||
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)
|
||||
})
|
||||
41
frontend/src/features/clickgui/tests/themeVars.test.ts
Normal file
41
frontend/src/features/clickgui/tests/themeVars.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { cssAngle, themeToCssVars } from '../themeVars'
|
||||
|
||||
const preset = (id: string) => {
|
||||
const p = PRESETS.find((x) => x.id === id)
|
||||
if (!p) throw new Error(id)
|
||||
return p
|
||||
}
|
||||
|
||||
test('maps every palette slot of Classic exactly', () => {
|
||||
const vars = themeToCssVars(preset('classic'))
|
||||
expect(vars['--gui-window-bg']).toBe('rgba(18, 25, 27, 0.941)')
|
||||
expect(vars['--gui-header']).toBe('rgba(20, 34, 38, 0.941)')
|
||||
expect(vars['--gui-stroke']).toBe('rgba(32, 48, 48, 0.376)')
|
||||
expect(vars['--gui-card-off']).toBe('rgba(51, 64, 70, 0)')
|
||||
expect(vars['--gui-text']).toBe('rgba(255, 255, 255, 1)')
|
||||
expect(vars['--gui-text-muted']).toBe('rgba(198, 210, 205, 0.533)')
|
||||
expect(vars['--gui-accent']).toBe('rgba(92, 200, 231, 1)')
|
||||
expect(vars['--gui-accent-soft']).toBe('rgba(92, 200, 231, 0.502)')
|
||||
expect(vars['--gui-stroke-soft']).toBe('rgba(255, 255, 255, 0.376)')
|
||||
for (const key of ['--gui-surface', '--gui-surface-hover', '--gui-card-on']) expect(vars).toHaveProperty(key)
|
||||
})
|
||||
|
||||
test('disabled gradients fall back to the flat color, enabled ones become linear-gradients', () => {
|
||||
const classic = themeToCssVars(preset('classic'))
|
||||
expect(classic['--gui-window-gradient']).toBe('rgba(18, 25, 27, 0.941)')
|
||||
expect(classic['--gui-header-gradient']).toBe('linear-gradient(180deg, rgba(39, 52, 55, 0.941), rgba(19, 32, 36, 0.941))')
|
||||
|
||||
const nitro = themeToCssVars(preset('nitro'))
|
||||
expect(nitro['--gui-window-gradient']).toBe('linear-gradient(135deg, rgba(54, 32, 95, 1), rgba(11, 125, 255, 1))')
|
||||
expect(nitro['--gui-window-from']).toBe('rgba(54, 32, 95, 1)')
|
||||
expect(nitro['--gui-window-to']).toBe('rgba(11, 125, 255, 1)')
|
||||
expect(nitro['--gui-window-angle']).toBe('135deg')
|
||||
})
|
||||
|
||||
test('mod angles convert to CSS angles', () => {
|
||||
expect(cssAngle(90)).toBe(180)
|
||||
expect(cssAngle(0)).toBe(90)
|
||||
expect(cssAngle(300)).toBe(30)
|
||||
})
|
||||
57
frontend/src/features/clickgui/themeVars.ts
Normal file
57
frontend/src/features/clickgui/themeVars.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import { argbToCss, type GradientSpec, type ThemeEntry, withAlpha } from '../themes/types'
|
||||
|
||||
type CssVars = CSSProperties & Record<`--${string}`, string>
|
||||
|
||||
/**
|
||||
* The mod measures gradient angles from the +X axis with 90° pointing down (its header
|
||||
* gradient is "top-lit" at 90°); CSS measures from "to top" clockwise, so 90° there is
|
||||
* left→right. Mod angle + 90 gives the CSS angle.
|
||||
*/
|
||||
export const cssAngle = (modAngle: number) => (((modAngle + 90) % 360) + 360) % 360
|
||||
|
||||
/** `linear-gradient(...)` when the slot's gradient is enabled, else the flat fallback color. */
|
||||
export function gradientToCss(spec: GradientSpec, flat: string): string {
|
||||
if (!spec.enabled) return argbToCss(flat)
|
||||
return `linear-gradient(${cssAngle(spec.angle)}deg, ${argbToCss(spec.start)}, ${argbToCss(spec.end)})`
|
||||
}
|
||||
|
||||
const GRADIENT_SLOTS = [
|
||||
['window', 'windowGradient', 'windowBg'],
|
||||
['header', 'headerGradient', 'windowHeader'],
|
||||
['surface', 'surfaceGradient', 'surface'],
|
||||
['card', 'cardGradient', 'cardEnabled'],
|
||||
['stroke', 'strokeGradient', 'windowStroke'],
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Maps a mod theme to the CSS custom properties the ClickGui replica reads.
|
||||
* Besides the full `--gui-<slot>-gradient` strings it emits `-from`/`-to`/`-angle` parts:
|
||||
* those are registered as typed `@property`s so a theme switch cross-fades smoothly.
|
||||
*/
|
||||
export function themeToCssVars(entry: ThemeEntry): CssVars {
|
||||
const t = entry.theme
|
||||
const vars: CssVars = {
|
||||
'--gui-window-bg': argbToCss(t.windowBg),
|
||||
'--gui-header': argbToCss(t.windowHeader),
|
||||
'--gui-stroke': argbToCss(t.windowStroke),
|
||||
'--gui-surface': argbToCss(t.surface),
|
||||
'--gui-surface-hover': argbToCss(t.surfaceHover),
|
||||
'--gui-card-on': argbToCss(t.cardEnabled),
|
||||
'--gui-card-off': argbToCss(t.cardDisabled),
|
||||
'--gui-text': argbToCss(t.textPrimary),
|
||||
'--gui-text-muted': argbToCss(t.textMuted),
|
||||
'--gui-accent': argbToCss(t.accent),
|
||||
'--gui-accent-soft': argbToCss(t.accentSoft),
|
||||
'--gui-stroke-soft': argbToCss(t.strokeSoft),
|
||||
'--gui-accent-glow': withAlpha(t.accent, 0.45),
|
||||
}
|
||||
for (const [slot, key, flat] of GRADIENT_SLOTS) {
|
||||
const spec = entry[key]
|
||||
vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat])
|
||||
vars[`--gui-${slot}-from`] = argbToCss(spec.enabled ? spec.start : t[flat])
|
||||
vars[`--gui-${slot}-to`] = argbToCss(spec.enabled ? spec.end : t[flat])
|
||||
vars[`--gui-${slot}-angle`] = `${cssAngle(spec.angle)}deg`
|
||||
}
|
||||
return vars
|
||||
}
|
||||
27
frontend/src/features/download/DownloadButton.tsx
Normal file
27
frontend/src/features/download/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { DOWNLOAD_URL, formatSize } from './api'
|
||||
import '../landing/styles/landing.css'
|
||||
|
||||
type Props = {
|
||||
size?: 'md' | 'lg'
|
||||
/** Latest release tag (e.g. `0.1.1`); with `bytes` it renders a size hint under the label. */
|
||||
version?: string
|
||||
bytes?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The primary one-click download link. Kept query-free so the landing stays light. */
|
||||
export function DownloadButton({ size = 'md', version, bytes, className = '' }: Props) {
|
||||
const { t, i18n } = useTranslation('download')
|
||||
const hint = version && bytes ? t('versionHint', { version, size: formatSize(bytes, i18n.language) }) : null
|
||||
const pad = size === 'lg' ? 'px-7 py-4 text-lg' : ''
|
||||
|
||||
return (
|
||||
<span className={`inline-flex flex-col items-start gap-2 ${className}`}>
|
||||
<a href={DOWNLOAD_URL} download className={`lv-cta-primary ${pad}`}>
|
||||
{t('button')}
|
||||
</a>
|
||||
<span className="font-code text-xs text-frost">{hint ?? t('hint')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
43
frontend/src/features/download/ReleaseNotes.tsx
Normal file
43
frontend/src/features/download/ReleaseNotes.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import Markdown from 'react-markdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fetchReleases } from './api'
|
||||
|
||||
/**
|
||||
* Changelog feed. Markdown bodies are rendered by react-markdown, which does not
|
||||
* enable raw HTML — so a `<script>` or `onerror=` in a release body stays inert text.
|
||||
*/
|
||||
export function ReleaseNotes() {
|
||||
const { t, i18n } = useTranslation('download')
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['releases'],
|
||||
queryFn: fetchReleases,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isError || !data || data.length === 0) {
|
||||
return (
|
||||
<p className="rounded-panel border border-white/10 bg-black/30 p-6 text-frost">{t('notes.empty')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
const date = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' })
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-8">
|
||||
{data.map((r) => (
|
||||
<li key={r.tag_name} className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 className="font-display text-xl font-bold text-snow">{r.name || r.tag_name}</h3>
|
||||
<time className="font-code text-xs text-frost" dateTime={r.published_at}>
|
||||
{date.format(new Date(r.published_at))}
|
||||
</time>
|
||||
</div>
|
||||
<div className="lv-markdown text-[0.95rem] leading-relaxed text-snow/85">
|
||||
<Markdown>{r.body}</Markdown>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
45
frontend/src/features/download/api.ts
Normal file
45
frontend/src/features/download/api.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
const DEFAULT_REPO = 'loki5512344/LoVisual-'
|
||||
|
||||
/** `owner/name` of the GitHub repository that publishes mod releases. */
|
||||
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
|
||||
|
||||
/**
|
||||
* One-click download of the newest build: GitHub redirects `releases/latest/download/<asset>`
|
||||
* straight to the file, so every release must carry an asset named exactly `lovisual.jar`.
|
||||
*/
|
||||
export const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/latest/download/lovisual.jar`
|
||||
|
||||
/** A published GitHub release, narrowed to the fields the download page renders. */
|
||||
export type Release = {
|
||||
tag_name: string
|
||||
name: string
|
||||
published_at: string
|
||||
body: string
|
||||
html_url: string
|
||||
assets: { name: string; size: number; download_count: number }[]
|
||||
}
|
||||
|
||||
/** Ten most recent releases from the public GitHub API (unauthenticated, ~60 req/h/IP). */
|
||||
export async function fetchReleases(): Promise<Release[]> {
|
||||
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=10`, {
|
||||
headers: { accept: 'application/vnd.github+json' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`)
|
||||
return (await res.json()) as Release[]
|
||||
}
|
||||
|
||||
/** Total bytes of `.jar` assets in a release — the download button's size hint. */
|
||||
export function releaseSize(release: Release): number {
|
||||
return release.assets.reduce((sum, a) => (a.name.endsWith('.jar') ? sum + a.size : sum), 0)
|
||||
}
|
||||
|
||||
/** Human size in megabytes, locale-aware (e.g. «4,2 МБ» / "4.2 MB"). */
|
||||
export function formatSize(bytes: number, locale: string): string {
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'unit',
|
||||
unit: 'megabyte',
|
||||
unitDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(mb)
|
||||
}
|
||||
29
frontend/src/features/download/i18n/en.ts
Normal file
29
frontend/src/features/download/i18n/en.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { downloadRu } from './ru'
|
||||
|
||||
export const downloadEn = {
|
||||
nav: 'Download',
|
||||
button: 'Download for free',
|
||||
hint: 'One .jar straight from GitHub',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
},
|
||||
notes: {
|
||||
title: "What's new",
|
||||
allReleases: 'All releases on GitHub',
|
||||
empty: 'The changelog will appear once the first public release is out.',
|
||||
downloads: '{count} downloads',
|
||||
},
|
||||
} satisfies Translation<typeof downloadRu>
|
||||
26
frontend/src/features/download/i18n/ru.ts
Normal file
26
frontend/src/features/download/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const downloadRu = {
|
||||
nav: 'Скачать',
|
||||
button: 'Скачать бесплатно',
|
||||
hint: 'Один .jar прямо с GitHub',
|
||||
versionHint: 'v{version} · {size}',
|
||||
page: {
|
||||
title: 'Скачать LoVisual',
|
||||
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл — без установщиков и без рекламы.',
|
||||
reqTitle: 'Что понадобится',
|
||||
reqMc: 'Minecraft',
|
||||
reqLoader: 'Fabric Loader',
|
||||
reqApi: 'Fabric API',
|
||||
reqOptionalTitle: 'По желанию для максимального FPS',
|
||||
reqOptional: 'Sodium и Iris — мод не требует их, но дружит с ними.',
|
||||
installTitle: 'Установка в три шага',
|
||||
step1: 'Скачай lovisual.jar кнопкой выше.',
|
||||
step2: 'Положи файл в папку mods (создай её, если нет).',
|
||||
step3: 'Запусти Minecraft с профилем Fabric — мод активен сразу.',
|
||||
},
|
||||
notes: {
|
||||
title: 'Что нового',
|
||||
allReleases: 'Все релизы на GitHub',
|
||||
empty: 'Список изменений появится, когда выйдет первый публичный релиз.',
|
||||
downloads: '{count} загрузок',
|
||||
},
|
||||
} as const
|
||||
49
frontend/src/features/download/tests/download.test.tsx
Normal file
49
frontend/src/features/download/tests/download.test.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { DownloadButton } from '../DownloadButton'
|
||||
import { ReleaseNotes } from '../ReleaseNotes'
|
||||
|
||||
function withQuery(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
const release = (body: string) => ({
|
||||
tag_name: 'v0.1.1',
|
||||
name: 'v0.1.1',
|
||||
published_at: '2026-09-01T00:00:00Z',
|
||||
body,
|
||||
html_url: 'https://github.com/x/y/releases/tag/v0.1.1',
|
||||
assets: [{ name: 'lovisual.jar', size: 4_400_000, download_count: 12 }],
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
test('DownloadButton links straight to the latest lovisual.jar', () => {
|
||||
const { container } = withQuery(<DownloadButton />)
|
||||
const link = container.querySelector('a')
|
||||
expect(link?.getAttribute('href')).toBe(
|
||||
'https://github.com/loki5512344/LoVisual-/releases/latest/download/lovisual.jar',
|
||||
)
|
||||
expect(link?.hasAttribute('download')).toBe(true)
|
||||
})
|
||||
|
||||
test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [release('# Заголовок\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>')],
|
||||
} as unknown as Response)
|
||||
|
||||
const { container } = withQuery(<ReleaseNotes />)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Заголовок' })).toBeInTheDocument()
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
test('ReleaseNotes shows the fallback when the releases fetch fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down'))
|
||||
withQuery(<ReleaseNotes />)
|
||||
expect(await screen.findByText(/первый публичный релиз/)).toBeInTheDocument()
|
||||
})
|
||||
66
frontend/src/features/landing/CommandHero.tsx
Normal file
66
frontend/src/features/landing/CommandHero.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
|
||||
import { ButtonLink } from '../../shared/ui/Button'
|
||||
import { useSession } from '../auth/session'
|
||||
|
||||
const COMMAND = '%config load 7KQ3M9XA'
|
||||
const TYPE_DELAY_MS = 60
|
||||
|
||||
|
||||
export function CommandHero() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { status } = useSession()
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<span className="text-ice">{t('cloud.headlinePart2')}</span>
|
||||
</h2>
|
||||
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{status === 'authenticated' ? (
|
||||
<ButtonLink to="/configs">{t('cloud.ctaAuthenticated')}</ButtonLink>
|
||||
) : (
|
||||
<ButtonLink to="/register">{t('cloud.ctaAnonymous')}</ButtonLink>
|
||||
)}
|
||||
<ButtonLink to="/showcase" variant="quiet">
|
||||
{t('cloud.ctaShowcase')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
<ChatBar />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatBar() {
|
||||
const { t } = useTranslation('landing')
|
||||
const [typed, setTyped] = useState(() => (prefersReducedMotion() ? COMMAND.length : 0))
|
||||
const done = typed >= COMMAND.length
|
||||
|
||||
useEffect(() => {
|
||||
if (done) return
|
||||
const t = setInterval(() => setTyped((n) => Math.min(n + 1, COMMAND.length)), TYPE_DELAY_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [done])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t('cloud.chatAriaLabel')}
|
||||
className="mt-6 flex min-h-[4.5rem] w-full max-w-xl flex-col justify-end gap-1 rounded-md border border-white/5 bg-black/55 px-3 py-2 font-code text-base text-snow shadow-[0_0_40px_-12px_var(--page-accent-glow)]"
|
||||
>
|
||||
<p aria-hidden="true" className="whitespace-pre-wrap break-all">
|
||||
{COMMAND.slice(0, typed)}
|
||||
{done ? null : <span className="animate-pulse text-frost">▌</span>}
|
||||
</p>
|
||||
{done ? (
|
||||
<p aria-hidden="true" className="text-ice">
|
||||
{t('cloud.chatConfirmation')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/features/landing/HowItWorks.tsx
Normal file
56
frontend/src/features/landing/HowItWorks.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export function HowItWorks() {
|
||||
const { t } = useTranslation('landing')
|
||||
const slots = [
|
||||
{ name: t('howItWorks.slotNamePvp'), code: '7KQ3M9XA' },
|
||||
{ name: t('howItWorks.slotNameStream'), code: 'R2WD8HNC' },
|
||||
{ name: t('howItWorks.slotNameLazy'), code: 'L5TB4QZE' },
|
||||
{ name: null, code: null },
|
||||
]
|
||||
const linkSteps = [t('howItWorks.linkStep1'), t('howItWorks.linkStep2'), t('howItWorks.linkStep3')]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 pb-20">
|
||||
<section className="grid items-center gap-8 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.slotsTitle')}</h2>
|
||||
<p className="max-w-[60ch] text-frost">{t('howItWorks.slotsDesc')}</p>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-3" aria-label={t('howItWorks.slotsAriaLabel')}>
|
||||
{slots.map((slot, i) => (
|
||||
<li key={i} className="flex min-w-0 flex-col gap-1 rounded-panel border border-shoal bg-slate px-4 py-3">
|
||||
{slot.name ? (
|
||||
<>
|
||||
<span className="truncate text-snow">{slot.name}</span>
|
||||
<span className="font-code text-sm tracking-wider text-ice">{slot.code}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="italic text-frost">{t('howItWorks.emptySlot')}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.linkTitle')}</h2>
|
||||
<ol className="flex max-w-[60ch] list-decimal flex-col gap-2 pl-6 marker:text-frost">
|
||||
{linkSteps.map((step) => (
|
||||
<li key={step} className="pl-1">
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<p className="max-w-[60ch] text-frost">
|
||||
{t('howItWorks.showcaseText')}{' '}
|
||||
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('howItWorks.showcaseLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
frontend/src/features/landing/effects/ParticleField.tsx
Normal file
138
frontend/src/features/landing/effects/ParticleField.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
|
||||
|
||||
const MAX_PARTICLES = 150
|
||||
const MAX_DPR = 2
|
||||
const SPRITE = 64
|
||||
|
||||
type Particle = { x: number; y: number; z: number; vx: number; vy: number; r: number; phase: number; speed: number }
|
||||
|
||||
/** A soft glowing dot tinted with `color`, drawn once and stamped for every particle (cheap "bloom"). */
|
||||
function makeSprite(color: string): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas')
|
||||
c.width = c.height = SPRITE
|
||||
const g = c.getContext('2d')
|
||||
if (!g) return c
|
||||
const h = SPRITE / 2
|
||||
const grad = g.createRadialGradient(h, h, 0, h, h, h)
|
||||
grad.addColorStop(0, 'rgba(255,255,255,1)')
|
||||
grad.addColorStop(0.12, color)
|
||||
grad.addColorStop(0.4, color.replace(/rgb\((.+)\)/, 'rgba($1, 0.25)'))
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
g.fillStyle = grad
|
||||
g.fillRect(0, 0, SPRITE, SPRITE)
|
||||
return c
|
||||
}
|
||||
|
||||
function spawn(w: number, h: number, anywhere: boolean): Particle {
|
||||
const z = 0.35 + Math.random() * 0.65
|
||||
return {
|
||||
x: Math.random() * w,
|
||||
y: anywhere ? Math.random() * h : h + 20,
|
||||
z,
|
||||
vx: (Math.random() - 0.5) * 0.12,
|
||||
vy: -(0.08 + Math.random() * 0.3) * z,
|
||||
r: (2 + Math.random() * 7) * z,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.6 + Math.random() * 1.4,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas of drifting glowing particles (the mod's AmbientParticles + bloom look).
|
||||
* `color` is an opaque `rgb(r, g, b)`. Pauses off-screen; a single static frame under reduced motion.
|
||||
*/
|
||||
export function ParticleField({ color, className = '' }: { color: string; className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const spriteRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
spriteRef.current = makeSprite(color)
|
||||
}, [color])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
const reduced = prefersReducedMotion()
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
|
||||
let w = 0
|
||||
let h = 0
|
||||
let particles: Particle[] = []
|
||||
let frame = 0
|
||||
let visible = true
|
||||
let time = 0
|
||||
const pointer = { x: 0, y: 0, tx: 0, ty: 0 }
|
||||
|
||||
const resize = () => {
|
||||
w = canvas.clientWidth
|
||||
h = canvas.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const count = Math.min(MAX_PARTICLES, Math.round((w * h) / 8000))
|
||||
particles = Array.from({ length: count }, () => spawn(w, h, true))
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
const sprite = spriteRef.current
|
||||
if (!sprite) return
|
||||
for (const p of particles) {
|
||||
const twinkle = 0.55 + 0.45 * Math.sin(time * 0.002 * p.speed + p.phase)
|
||||
const size = p.r * 4
|
||||
ctx.globalAlpha = twinkle * (0.35 + p.z * 0.65)
|
||||
ctx.drawImage(sprite, p.x + pointer.x * p.z - size / 2, p.y + pointer.y * p.z - size / 2, size, size)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(48, time ? now - time : 16) / 16
|
||||
time = now
|
||||
pointer.x += (pointer.tx - pointer.x) * 0.04
|
||||
pointer.y += (pointer.ty - pointer.y) * 0.04
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i]
|
||||
p.x += (p.vx + Math.sin(now * 0.0004 + p.phase) * 0.08) * dt
|
||||
p.y += p.vy * dt
|
||||
if (p.y < -30 || p.x < -30 || p.x > w + 30) particles[i] = spawn(w, h, false)
|
||||
}
|
||||
draw()
|
||||
frame = visible ? requestAnimationFrame(step) : 0
|
||||
}
|
||||
|
||||
resize()
|
||||
if (reduced) {
|
||||
draw()
|
||||
return
|
||||
}
|
||||
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
pointer.tx = (e.clientX / window.innerWidth - 0.5) * -30
|
||||
pointer.ty = (e.clientY / window.innerHeight - 0.5) * -20
|
||||
}
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize)
|
||||
ro?.observe(canvas)
|
||||
const io =
|
||||
typeof IntersectionObserver === 'undefined'
|
||||
? null
|
||||
: new IntersectionObserver(([entry]) => {
|
||||
visible = entry.isIntersecting && !document.hidden
|
||||
if (visible && !frame) frame = requestAnimationFrame(step)
|
||||
})
|
||||
io?.observe(canvas)
|
||||
window.addEventListener('pointermove', onPointer, { passive: true })
|
||||
frame = requestAnimationFrame(step)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
ro?.disconnect()
|
||||
io?.disconnect()
|
||||
window.removeEventListener('pointermove', onPointer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas ref={canvasRef} aria-hidden="true" data-testid="particle-field" className={`pointer-events-none absolute inset-0 h-full w-full ${className}`} />
|
||||
}
|
||||
47
frontend/src/features/landing/effects/Tilt.tsx
Normal file
47
frontend/src/features/landing/effects/Tilt.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
196
frontend/src/features/landing/effects/VoxelScene.tsx
Normal file
196
frontend/src/features/landing/effects/VoxelScene.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
|
||||
|
||||
/** Deterministic PRNG so the landscape looks the same on every visit. */
|
||||
function mulberry32(seed: number) {
|
||||
let a = seed
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
type Layer = {
|
||||
seed: number
|
||||
block: number
|
||||
/** Surface baseline as a fraction of height, and hill amplitude in blocks. */
|
||||
base: number
|
||||
amp: number
|
||||
top: string
|
||||
side: string
|
||||
deep: string
|
||||
trees: number
|
||||
torches: number
|
||||
/** Scroll parallax factor (fraction of scroll the layer lags behind). */
|
||||
lag: number
|
||||
}
|
||||
|
||||
const LAYERS: Layer[] = [
|
||||
{ seed: 7, block: 10, base: 0.5, amp: 9, top: '#4a3563', side: '#3a2a52', deep: '#302445', trees: 0, torches: 0, lag: 0.42 },
|
||||
{ seed: 21, block: 16, base: 0.6, amp: 6, top: '#35304f', side: '#241f38', deep: '#1c1830', trees: 0.08, torches: 0, lag: 0.28 },
|
||||
{ seed: 43, block: 26, base: 0.74, amp: 4, top: '#3d6b45', side: '#1c2a26', deep: '#141c1c', trees: 0.12, torches: 0.1, lag: 0.12 },
|
||||
]
|
||||
|
||||
function shade(hex: string, k: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16)
|
||||
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v * k)))
|
||||
return `rgb(${f((n >> 16) & 255)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`
|
||||
}
|
||||
|
||||
function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, color: string, k: number) {
|
||||
ctx.fillStyle = shade(color, k)
|
||||
ctx.fillRect(x, y, s, s)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)'
|
||||
ctx.fillRect(x, y + s - 2, s, 2)
|
||||
ctx.fillRect(x + s - 1, y, 1, s)
|
||||
}
|
||||
|
||||
function drawLayer(ctx: CanvasRenderingContext2D, w: number, h: number, layer: Layer, accent: string) {
|
||||
const rnd = mulberry32(layer.seed)
|
||||
const s = layer.block
|
||||
const cols = Math.ceil(w / s) + 1
|
||||
const p1 = rnd() * 10
|
||||
const p2 = rnd() * 10
|
||||
const heights = Array.from({ length: cols }, (_, i) => {
|
||||
const n = Math.sin(i * 0.11 + p1) * 0.6 + Math.sin(i * 0.037 + p2) * 0.9 + Math.sin(i * 0.29 + p1 * 2) * 0.25
|
||||
return Math.round(h * layer.base / s - n * layer.amp)
|
||||
})
|
||||
const glow: [number, number][] = []
|
||||
heights.forEach((row, i) => {
|
||||
const x = i * s
|
||||
for (let r = row; r * s < h; r++) {
|
||||
const depth = r - row
|
||||
const color = depth === 0 ? layer.top : depth < 3 ? layer.side : layer.deep
|
||||
drawBlock(ctx, x, r * s, s, color, 0.9 + rnd() * 0.2)
|
||||
}
|
||||
// A thin accent rim on the sunward edge of the surface, like the mod's bloom catching the light.
|
||||
ctx.fillStyle = accent
|
||||
ctx.globalAlpha = 0.28
|
||||
ctx.fillRect(x, row * s, s, Math.max(1, s * 0.12))
|
||||
ctx.globalAlpha = 1
|
||||
const flat = heights[i + 1] === row && heights[i - 1] === row
|
||||
if (flat && rnd() < layer.trees) drawTree(ctx, x, row * s, s, rnd)
|
||||
else if (rnd() < layer.torches) {
|
||||
ctx.fillStyle = '#6b4a2b'
|
||||
ctx.fillRect(x + s * 0.42, row * s - s * 0.7, s * 0.16, s * 0.7)
|
||||
ctx.fillStyle = '#ffd27a'
|
||||
ctx.fillRect(x + s * 0.38, row * s - s * 0.9, s * 0.24, s * 0.24)
|
||||
glow.push([x + s / 2, row * s - s * 0.8])
|
||||
}
|
||||
})
|
||||
for (const [gx, gy] of glow) {
|
||||
const g = ctx.createRadialGradient(gx, gy, 0, gx, gy, s * 3)
|
||||
g.addColorStop(0, 'rgba(255, 190, 110, 0.55)')
|
||||
g.addColorStop(1, 'rgba(255, 190, 110, 0)')
|
||||
ctx.fillStyle = g
|
||||
ctx.fillRect(gx - s * 3, gy - s * 3, s * 6, s * 6)
|
||||
}
|
||||
}
|
||||
|
||||
function drawTree(ctx: CanvasRenderingContext2D, x: number, groundY: number, s: number, rnd: () => number) {
|
||||
const trunk = 2 + Math.floor(rnd() * 2)
|
||||
for (let i = 1; i <= trunk; i++) drawBlock(ctx, x, groundY - i * s, s, '#3b2a22', 0.9)
|
||||
const top = groundY - (trunk + 1) * s
|
||||
for (let dy = 0; dy < 2; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) drawBlock(ctx, x + dx * s, top - dy * s, s, '#1f3b2e', 0.85 + rnd() * 0.3)
|
||||
}
|
||||
drawBlock(ctx, x, top - 2 * s, s, '#244536', 1)
|
||||
}
|
||||
|
||||
function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) {
|
||||
const rnd = mulberry32(99)
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const size = rnd() < 0.85 ? 2 : 3
|
||||
ctx.fillStyle = `rgba(255, 245, 230, ${0.25 + rnd() * 0.6})`
|
||||
ctx.fillRect(Math.floor(rnd() * w), Math.floor(rnd() * h * 0.45), size, size)
|
||||
}
|
||||
// Square setting sun with a warm halo.
|
||||
const sx = w * 0.72
|
||||
const sy = h * 0.46
|
||||
const halo = ctx.createRadialGradient(sx, sy, 0, sx, sy, h * 0.4)
|
||||
halo.addColorStop(0, 'rgba(255, 170, 110, 0.55)')
|
||||
halo.addColorStop(1, 'rgba(255, 120, 90, 0)')
|
||||
ctx.fillStyle = halo
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
const sun = Math.max(48, h * 0.1)
|
||||
ctx.fillStyle = '#ffcf8a'
|
||||
ctx.fillRect(sx - sun / 2, sy - sun / 2, sun, sun)
|
||||
ctx.fillStyle = '#ffe7b8'
|
||||
ctx.fillRect(sx - sun / 4, sy - sun / 4, sun / 2, sun / 2)
|
||||
// Blocky clouds.
|
||||
for (let c = 0; c < 7; c++) {
|
||||
const cx = rnd() * w
|
||||
const cy = h * (0.12 + rnd() * 0.3)
|
||||
const u = 14
|
||||
ctx.fillStyle = `rgba(${200 + rnd() * 40}, ${120 + rnd() * 40}, ${170 + rnd() * 40}, 0.2)`
|
||||
const len = 4 + Math.floor(rnd() * 7)
|
||||
for (let i = 0; i < len; i++) ctx.fillRect(cx + i * u, cy + (i % 3 === 0 ? -u : 0), u, u * (i % 3 === 0 ? 2 : 1))
|
||||
}
|
||||
}
|
||||
|
||||
/** Procedural blocky dusk landscape with layered scroll parallax. Purely decorative. */
|
||||
export function VoxelScene({ accent, className = '' }: { accent: string; className?: string }) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const canvases = useRef<(HTMLCanvasElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
const paint = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const w = root.clientWidth
|
||||
const h = root.clientHeight * 1.25
|
||||
canvases.current.forEach((canvas, i) => {
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
if (i === 0) drawSky(ctx, w, h)
|
||||
else drawLayer(ctx, w, h, LAYERS[i - 1], accent)
|
||||
})
|
||||
}
|
||||
paint()
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(paint)
|
||||
ro?.observe(root)
|
||||
return () => ro?.disconnect()
|
||||
}, [accent])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion()) return
|
||||
let frame = 0
|
||||
const lags = [0.5, ...LAYERS.map((l) => l.lag)]
|
||||
const onScroll = () => {
|
||||
if (frame) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
const y = Math.min(window.scrollY, 1200)
|
||||
canvases.current.forEach((c, i) => {
|
||||
if (c) c.style.transform = `translate3d(0, ${(y * lags[i]).toFixed(1)}px, 0)`
|
||||
})
|
||||
})
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} aria-hidden="true" className={`lv-voxel pointer-events-none absolute inset-0 overflow-hidden ${className}`}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<canvas
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
canvases.current[i] = el
|
||||
}}
|
||||
className="absolute inset-x-0 top-0 h-[125%] w-full will-change-transform"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
frontend/src/features/landing/effects/useInView.ts
Normal file
26
frontend/src/features/landing/effects/useInView.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { type RefObject, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Tracks whether `ref` intersects the viewport. Without IntersectionObserver (old browsers,
|
||||
* jsdom) the element counts as visible so content never stays hidden.
|
||||
*/
|
||||
export function useInView<T extends Element>(ref: RefObject<T | null>, options: { once?: boolean; rootMargin?: string } = {}): boolean {
|
||||
const [inView, setInView] = useState(() => typeof IntersectionObserver === 'undefined')
|
||||
const { once = false, rootMargin = '0px' } = options
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setInView(entry.isIntersecting)
|
||||
if (entry.isIntersecting && once) observer.disconnect()
|
||||
},
|
||||
{ rootMargin },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [ref, once, rootMargin])
|
||||
|
||||
return inView
|
||||
}
|
||||
103
frontend/src/features/landing/i18n/en.ts
Normal file
103
frontend/src/features/landing/i18n/en.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { landingRu } from './ru'
|
||||
|
||||
export const landingEn = {
|
||||
hero: {
|
||||
badge: 'Minecraft 26.2 · Fabric',
|
||||
headlineLead: 'Visuals',
|
||||
headlineAccent: 'you can see',
|
||||
lead: 'A free legit mod for Minecraft 26.2: 52 visual modules, cloud configs and themes you build yourself.',
|
||||
download: 'Download for free',
|
||||
downloadHint: 'One .jar straight from GitHub',
|
||||
register: 'Create account',
|
||||
facts: {
|
||||
modules: 'modules',
|
||||
visuals: 'for visuals',
|
||||
themes: 'built-in themes',
|
||||
},
|
||||
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.',
|
||||
cta: 'Build your own theme',
|
||||
current: 'Now',
|
||||
swatches: 'Built-in themes',
|
||||
palette: 'Theme palette',
|
||||
},
|
||||
cloud: {
|
||||
headlinePart1: 'Your LoVisual settings',
|
||||
headlinePart2: 'on any computer',
|
||||
subtitle: 'Four cloud slots, a code you can share with friends, and a showcase of ready-made configs.',
|
||||
ctaAuthenticated: 'My configs',
|
||||
ctaAnonymous: 'Create account',
|
||||
ctaShowcase: 'View showcase',
|
||||
chatConfirmation: '[LoVisual] Loaded config "pvp"',
|
||||
chatAriaLabel: 'Example: loading a config by code in the game chat',
|
||||
},
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
showcaseLink: 'Open showcase',
|
||||
},
|
||||
hud: {
|
||||
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.',
|
||||
reset: 'Reset',
|
||||
widgetFps: 'FPS',
|
||||
widgetCoords: 'Coordinates',
|
||||
widgetModules: 'Module list',
|
||||
widgetKeys: 'Key presses',
|
||||
widgetPing: 'Ping',
|
||||
unitMs: 'ms',
|
||||
dragAria: 'Draggable HUD widget "{{name}}". Arrow keys move it.',
|
||||
},
|
||||
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.',
|
||||
columnsAria: 'Module categories',
|
||||
},
|
||||
showcase: {
|
||||
eyebrow: 'Showcase',
|
||||
title: 'Ready-made configs from the community',
|
||||
lead: 'The most popular showcase configs. Copy any of them into your slot with one button.',
|
||||
cta: 'Open showcase',
|
||||
example: 'Example',
|
||||
by: 'by {{author}}',
|
||||
downloads: '{{count}} downloads',
|
||||
},
|
||||
faq: {
|
||||
eyebrow: 'FAQ',
|
||||
title: 'Frequently asked',
|
||||
q1: 'Is it really free?',
|
||||
a1: 'Yes. The mod is free and open source: no subscriptions, no pay-to-unlock features.',
|
||||
q2: 'Which Minecraft versions are supported?',
|
||||
a2: 'Minecraft 26.2 on the Fabric loader.',
|
||||
q3: 'Do I need an account?',
|
||||
a3: 'Not for the mod itself. An account is only needed for cloud slots and the config showcase.',
|
||||
q4: 'Is it legit? Will I get banned?',
|
||||
a4: 'LoVisual is a visuals mod: it reads nothing from memory and sends nothing to the server. Your admin decides.',
|
||||
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.',
|
||||
downloadCta: 'Download for free',
|
||||
downloadNote: 'Minecraft 26.2 · Fabric · the latest release',
|
||||
releaseLink: 'Releases and versions',
|
||||
},
|
||||
} satisfies Translation<typeof landingRu>
|
||||
100
frontend/src/features/landing/i18n/ru.ts
Normal file
100
frontend/src/features/landing/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
export const landingRu = {
|
||||
hero: {
|
||||
badge: 'Minecraft 26.2 · Fabric',
|
||||
headlineLead: 'Визуалы,',
|
||||
headlineAccent: 'которые видно',
|
||||
lead: 'Бесплатный легит-мод для Minecraft 26.2: 52 визуальных модуля, облачные конфиги и темы, которые ты собираешь сам.',
|
||||
download: 'Скачать бесплатно',
|
||||
downloadHint: 'Один .jar прямо с GitHub',
|
||||
register: 'Создать аккаунт',
|
||||
facts: {
|
||||
modules: 'модулей',
|
||||
visuals: 'про визуал',
|
||||
themes: 'встроенных тем',
|
||||
},
|
||||
tryIt: 'Это настоящий интерфейс — наведи и покликай',
|
||||
},
|
||||
themeStrip: {
|
||||
eyebrow: 'Темы',
|
||||
title: 'Сотни оттенков. Твой — один.',
|
||||
lead: 'Все встроенные темы прямо из мода. Нажми на любую — ClickGui и весь сайт перекрасятся на лету.',
|
||||
cta: 'Собрать свою тему',
|
||||
current: 'Сейчас',
|
||||
swatches: 'Встроенные темы',
|
||||
palette: 'Палитра темы',
|
||||
},
|
||||
cloud: {
|
||||
headlinePart1: 'Твои настройки LoVisual',
|
||||
headlinePart2: 'на любом компьютере',
|
||||
subtitle: 'Четыре облачных слота, код, которым делишься с друзьями, и витрина готовых конфигов.',
|
||||
ctaAuthenticated: 'Мои конфиги',
|
||||
ctaAnonymous: 'Создать аккаунт',
|
||||
ctaShowcase: 'Смотреть витрину',
|
||||
chatConfirmation: '[LoVisual] Конфиг «pvp» загружен',
|
||||
chatAriaLabel: 'Пример: загрузка конфига по коду в чате игры',
|
||||
},
|
||||
howItWorks: {
|
||||
slotsTitle: 'Четыре слота в облаке',
|
||||
slotsDesc:
|
||||
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере — у каждого слота есть код, по которому его может загрузить и друг.',
|
||||
slotsAriaLabel: 'Пример слотов',
|
||||
emptySlot: 'пусто',
|
||||
slotNamePvp: 'pvp',
|
||||
slotNameStream: 'стрим',
|
||||
slotNameLazy: 'ленивый',
|
||||
linkTitle: 'Привязка игры',
|
||||
linkStep1: 'В моде открой вкладку аккаунта — там появится код.',
|
||||
linkStep2: 'Введи код на странице «Привязать игру».',
|
||||
linkStep3: 'Готово: мод входит в аккаунт сам, пароль ему не нужен.',
|
||||
showcaseText: 'Любой конфиг из витрины можно скопировать в свободный слот одной кнопкой.',
|
||||
showcaseLink: 'Открыть витрину',
|
||||
},
|
||||
hud: {
|
||||
eyebrow: 'HUD',
|
||||
title: 'Расставь элементы как в игре',
|
||||
lead: 'Те же виджеты, что рисует мод: FPS, координаты, список модулей, клавиши и пинг. Таскай их мышкой или стрелками.',
|
||||
caption: 'Перетащи — так же, как в игре.',
|
||||
reset: 'Сбросить',
|
||||
widgetFps: 'FPS',
|
||||
widgetCoords: 'Координаты',
|
||||
widgetModules: 'Список модулей',
|
||||
widgetKeys: 'Нажатия клавиш',
|
||||
widgetPing: 'Пинг',
|
||||
unitMs: 'мс',
|
||||
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки — двигать.',
|
||||
},
|
||||
wall: {
|
||||
eyebrow: 'Модули',
|
||||
title: '88 модулей. 52 — про красоту.',
|
||||
lead: 'Вся витрина категорий. Наведи на визуальный модуль — расскажу, что он делает.',
|
||||
columnsAria: 'Категории модулей',
|
||||
},
|
||||
showcase: {
|
||||
eyebrow: 'Витрина',
|
||||
title: 'Готовые конфиги от сообщества',
|
||||
lead: 'Самые популярные конфиги витрины. Любой можно скопировать в свой слот одной кнопкой.',
|
||||
cta: 'Открыть витрину',
|
||||
example: 'Пример',
|
||||
by: 'от {{author}}',
|
||||
downloads: '{{count}} загрузок',
|
||||
},
|
||||
faq: {
|
||||
eyebrow: 'Вопросы',
|
||||
title: 'Частые вопросы',
|
||||
q1: 'Это правда бесплатно?',
|
||||
a1: 'Да. Мод бесплатный и с открытым кодом: без подписок и без доната за функции.',
|
||||
q2: 'Какие версии Minecraft поддерживаются?',
|
||||
a2: 'Minecraft 26.2 на загрузчике Fabric.',
|
||||
q3: 'Нужен ли аккаунт?',
|
||||
a3: 'Для самого мода — нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
|
||||
q4: 'Это легитно? Забанят ли?',
|
||||
a4: 'LoVisual — визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
|
||||
q5: 'Как перенести настройки на другой компьютер?',
|
||||
a5: 'Сохрани конфиг в облачный слот и загрузи его по коду на любом другом ПК.',
|
||||
downloadTitle: 'Готов попробовать?',
|
||||
downloadLead: 'Один .jar прямо с GitHub — просто положи его в папку mods.',
|
||||
downloadCta: 'Скачать бесплатно',
|
||||
downloadNote: 'Minecraft 26.2 · Fabric · самый свежий релиз',
|
||||
releaseLink: 'Список релизов и версий',
|
||||
},
|
||||
} as const
|
||||
47
frontend/src/features/landing/sections/FaqDownload.tsx
Normal file
47
frontend/src/features/landing/sections/FaqDownload.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
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 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">
|
||||
<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">
|
||||
{items.map((it) => (
|
||||
<details key={it.q} className="lv-faq-item group py-3">
|
||||
<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" />
|
||||
</summary>
|
||||
<p className="mt-2 max-w-[52ch] text-sm leading-relaxed text-frost">{it.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</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">
|
||||
<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">
|
||||
{t('faq.downloadCta')}
|
||||
</a>
|
||||
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p>
|
||||
<a
|
||||
href={`https://github.com/${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-ice underline-offset-4 hover:underline"
|
||||
>
|
||||
{t('faq.releaseLink')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
109
frontend/src/features/landing/sections/Hero.tsx
Normal file
109
frontend/src/features/landing/sections/Hero.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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'
|
||||
|
||||
const rise = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 24 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] as const },
|
||||
})
|
||||
|
||||
function DownloadIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="size-5" aria-hidden="true">
|
||||
<path d="M10 3v10m0 0-4-4m4 4 4-4M4 16h12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { theme } = useActiveTheme()
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const inView = useInView(sectionRef)
|
||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||
const accent = `rgb(${r}, ${g}, ${b})`
|
||||
const facts = [
|
||||
{ n: '88', label: t('hero.facts.modules') },
|
||||
{ n: '52', label: t('hero.facts.visuals') },
|
||||
{ n: String(PRESETS.length), label: t('hero.facts.themes') },
|
||||
]
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<section
|
||||
ref={sectionRef}
|
||||
aria-labelledby="hero-title"
|
||||
className="lv-bleed relative isolate flex min-h-[calc(100svh-3.75rem)] items-center overflow-hidden"
|
||||
>
|
||||
<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="flex min-w-0 flex-col items-start gap-6">
|
||||
<motion.span
|
||||
{...rise(0)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1 font-code text-xs text-frost backdrop-blur"
|
||||
>
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hero.badge')}
|
||||
</motion.span>
|
||||
<motion.h1 {...rise(0.08)} id="hero-title" className="lv-hero-title font-display font-bold">
|
||||
{t('hero.headlineLead')} <span className="lv-hero-accent">{t('hero.headlineAccent')}</span>
|
||||
</motion.h1>
|
||||
<motion.p {...rise(0.16)} className="max-w-[34rem] text-lg leading-relaxed text-snow/80">
|
||||
{t('hero.lead')}
|
||||
</motion.p>
|
||||
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
|
||||
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
|
||||
<DownloadIcon />
|
||||
{t('hero.download')}
|
||||
</a>
|
||||
<ButtonLink to="/register" variant="quiet" className="bg-black/25 px-5 py-3 text-base backdrop-blur">
|
||||
{t('hero.register')}
|
||||
</ButtonLink>
|
||||
</motion.div>
|
||||
<motion.p {...rise(0.3)} className="-mt-2 font-code text-xs text-frost">
|
||||
{t('hero.downloadHint')}
|
||||
</motion.p>
|
||||
<motion.dl {...rise(0.36)} className="mt-2 flex flex-wrap gap-x-8 gap-y-3">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label} className="flex flex-col">
|
||||
<dt className="order-2 text-sm text-frost">{f.label}</dt>
|
||||
<dd className="lv-fact font-display text-3xl font-bold text-snow">{f.n}</dd>
|
||||
</div>
|
||||
))}
|
||||
</motion.dl>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.94, y: 30 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
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>
|
||||
<p className="flex items-center gap-2 text-sm text-frost">
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hero.tryIt')}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
210
frontend/src/features/landing/sections/HudPlayground.tsx
Normal file
210
frontend/src/features/landing/sections/HudPlayground.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { 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'
|
||||
|
||||
const STEP = 8
|
||||
/** Fallback playfield size, used before the box is measured (and under jsdom). */
|
||||
const FIELD = { w: 680, h: 380 }
|
||||
const WIDGET = { w: 150, h: 56 }
|
||||
|
||||
type Pos = { x: number; y: number }
|
||||
/** Real draggable HUD elements from the mod: features/gui/hud/draggable/impl. */
|
||||
type WidgetId = 'fps' | 'coords' | 'modules' | 'keys' | 'ping'
|
||||
|
||||
const START: Record<WidgetId, Pos> = {
|
||||
fps: { x: 20, y: 20 },
|
||||
coords: { x: 20, y: 310 },
|
||||
modules: { x: FIELD.w - 190, y: 20 },
|
||||
ping: { x: FIELD.w - 170, y: 150 },
|
||||
keys: { x: FIELD.w - 140, y: FIELD.h - 120 },
|
||||
}
|
||||
|
||||
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
|
||||
|
||||
/** Enabled modules as they would appear in the mod's ModuleList panel. */
|
||||
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
|
||||
|
||||
/** Widgets read the accent through --gui-accent so it cross-fades with the active theme. */
|
||||
const ACCENT = 'var(--gui-accent)'
|
||||
|
||||
function WidgetContent({ id, accent, unitMs }: { id: WidgetId; accent: string; unitMs: string }) {
|
||||
switch (id) {
|
||||
case 'fps':
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="font-code text-2xl leading-none font-bold" style={{ color: accent }}>
|
||||
240
|
||||
</span>
|
||||
<span className="text-[0.6rem] tracking-[0.15em] text-frost uppercase">FPS</span>
|
||||
</div>
|
||||
)
|
||||
case 'coords':
|
||||
return (
|
||||
<div className="font-code text-sm leading-none text-snow">
|
||||
<span style={{ color: accent }}>X</span> 128 <span style={{ color: accent }}>Y</span> 74 {' '}
|
||||
<span style={{ color: accent }}>Z</span> -204
|
||||
</div>
|
||||
)
|
||||
case 'modules':
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{MODULES_ON.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="rounded-[5px] border-r-2 bg-black/25 px-2 py-[3px] font-code text-[0.7rem] leading-none text-snow"
|
||||
style={{ borderRightColor: accent }}
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'keys': {
|
||||
const cap = (k: string, pressed = false) => (
|
||||
<span
|
||||
key={k}
|
||||
className="grid size-7 place-items-center rounded-[6px] border bg-white/5 font-code text-xs leading-none"
|
||||
style={{
|
||||
borderColor: pressed ? accent : 'rgb(255 255 255 / 0.12)',
|
||||
color: pressed ? accent : undefined,
|
||||
background: pressed ? `color-mix(in srgb, ${accent} 18%, transparent)` : undefined,
|
||||
}}
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{cap('W', true)}
|
||||
<div className="flex gap-1">
|
||||
{cap('A')}
|
||||
{cap('S', true)}
|
||||
{cap('D')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'ping':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-code text-sm leading-none text-snow">
|
||||
32 <span className="text-frost">{unitMs}</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className="flex items-end gap-[3px]">
|
||||
{[6, 10, 14, 18].map((h, i) => (
|
||||
<span
|
||||
key={h}
|
||||
className="w-[4px] rounded-sm"
|
||||
style={{ height: h, background: i < 2 ? accent : 'rgb(255 255 255 / 0.18)' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function HudPlayground() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { theme } = useActiveTheme()
|
||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null)
|
||||
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
|
||||
|
||||
const bounds = (el: HTMLElement) => {
|
||||
const field = fieldRef.current
|
||||
const w = field?.clientWidth || FIELD.w
|
||||
const h = field?.clientHeight || FIELD.h
|
||||
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
|
||||
}
|
||||
|
||||
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) } }))
|
||||
}
|
||||
|
||||
const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
const deltas: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0], ArrowUp: [0, -STEP], ArrowDown: [0, STEP],
|
||||
}
|
||||
const d = deltas[e.key]
|
||||
if (!d) return
|
||||
e.preventDefault()
|
||||
nudge(id, d[0], d[1], e.currentTarget)
|
||||
}
|
||||
|
||||
const onPointerDown = (id: WidgetId) => (e: React.PointerEvent<HTMLElement>) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
drag.current = { id, px: e.clientX, py: e.clientY, ox: pos[id].x, oy: pos[id].y }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {
|
||||
const d = drag.current
|
||||
if (!d) return
|
||||
const { maxX, maxY } = bounds(e.currentTarget)
|
||||
setPos((prev) => ({ ...prev, [d.id]: { x: clamp(d.ox + e.clientX - d.px, maxX), y: clamp(d.oy + e.clientY - d.py, maxY) } }))
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
drag.current = null
|
||||
}
|
||||
|
||||
const widgets: { id: WidgetId; label: string }[] = [
|
||||
{ id: 'fps', label: t('hud.widgetFps') },
|
||||
{ id: 'coords', label: t('hud.widgetCoords') },
|
||||
{ id: 'modules', label: t('hud.widgetModules') },
|
||||
{ id: 'keys', label: t('hud.widgetKeys') },
|
||||
{ id: 'ping', label: t('hud.widgetPing') },
|
||||
]
|
||||
|
||||
return (
|
||||
<section aria-labelledby="hud-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-14">
|
||||
<div className="flex min-w-0 flex-col items-start gap-4">
|
||||
<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)}>
|
||||
{t('hud.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
ref={fieldRef}
|
||||
style={themeToCssVars(theme)}
|
||||
className="lv-hud-field relative isolate overflow-hidden rounded-panel border border-white/10"
|
||||
>
|
||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} />
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/25" />
|
||||
{widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={w.label}
|
||||
aria-label={t('hud.dragAria', { name: w.label })}
|
||||
onKeyDown={onKeyDown(w.id)}
|
||||
onPointerDown={onPointerDown(w.id)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
||||
className="lv-hud-widget absolute touch-none select-none"
|
||||
>
|
||||
<WidgetContent id={w.id} accent={ACCENT} unitMs={t('hud.unitMs')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-frost">
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hud.caption')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
45
frontend/src/features/landing/sections/ModuleWall.tsx
Normal file
45
frontend/src/features/landing/sections/ModuleWall.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
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">
|
||||
<div className="flex min-w-0 flex-col items-start gap-4">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('wall.eyebrow')}</span>
|
||||
<h2 id="wall-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('wall.title')}</h2>
|
||||
<p className="max-w-[42ch] text-lg text-frost">{t('wall.lead')}</p>
|
||||
</div>
|
||||
|
||||
<div role="list" aria-label={t('wall.columnsAria')} className="lv-wall grid min-w-0 grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{CATEGORIES.map((cat, i) => (
|
||||
<div key={cat.id} role="listitem" className="lv-wall-col flex min-w-0 flex-col gap-2">
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
52
frontend/src/features/landing/sections/ShowcaseTeaser.tsx
Normal file
52
frontend/src/features/landing/sections/ShowcaseTeaser.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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 }
|
||||
|
||||
/** 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 },
|
||||
]
|
||||
|
||||
export function ShowcaseTeaser() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['showcase', 'popular'],
|
||||
queryFn: () => api.request<ShowcaseItem[]>('/showcase?sort=popular'),
|
||||
})
|
||||
const real = !isError && Array.isArray(data) ? data.slice(0, 3) : []
|
||||
const cards = real.length >= 3 ? real : EXAMPLES
|
||||
const isExample = real.length < 3
|
||||
|
||||
return (
|
||||
<section aria-labelledby="showcase-title" className="grid gap-10 py-20 sm:py-28">
|
||||
<div className="flex min-w-0 flex-col items-start gap-4">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('showcase.eyebrow')}</span>
|
||||
<h2 id="showcase-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('showcase.title')}</h2>
|
||||
<p className="max-w-[52ch] text-lg text-frost">{t('showcase.lead')}</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid min-w-0 gap-4 sm:grid-cols-3">
|
||||
{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>
|
||||
{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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('showcase.cta')}
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
95
frontend/src/features/landing/sections/ThemeStrip.tsx
Normal file
95
frontend/src/features/landing/sections/ThemeStrip.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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'
|
||||
|
||||
const PALETTE_KEYS: (keyof ThemeColors)[] = [
|
||||
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',
|
||||
]
|
||||
|
||||
/** Every built-in preset as a swatch; picking one re-themes the hero ClickGui and the page accent. */
|
||||
export function ThemeStrip() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { t: tt } = useTranslation('themes')
|
||||
const { theme, setTheme } = useActiveTheme()
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const inView = useInView(listRef, { once: true, rootMargin: '-40px' })
|
||||
|
||||
return (
|
||||
<section aria-labelledby="themes-title" className="relative grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)] lg:gap-14">
|
||||
<div className="flex min-w-0 flex-col items-start gap-5">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('themeStrip.eyebrow')}</span>
|
||||
<h2 id="themes-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">
|
||||
{t('themeStrip.title')}
|
||||
</h2>
|
||||
<p className="max-w-[40ch] text-lg text-frost">{t('themeStrip.lead')}</p>
|
||||
|
||||
<div className="lv-theme-now mt-2 w-full max-w-sm rounded-panel p-4" style={themeToCssVars(theme)}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-sm text-frost">{t('themeStrip.current')}</span>
|
||||
<span className="font-display text-lg font-bold" style={{ color: 'var(--gui-accent)' }}>
|
||||
{theme.name}
|
||||
</span>
|
||||
</div>
|
||||
<ul aria-label={t('themeStrip.palette')} className="mt-3 grid grid-cols-8 gap-1.5">
|
||||
{PALETTE_KEYS.map((k) => (
|
||||
<li
|
||||
key={k}
|
||||
title={tt(`colors.${k}`)}
|
||||
className="lv-chip aspect-square rounded-md"
|
||||
style={{ background: argbToCss(theme.theme[k]) }}
|
||||
>
|
||||
<span className="sr-only">{`${tt(`colors.${k}`)}: ${theme.theme[k]}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ButtonLink to="/themes" className="mt-2 px-5 py-3 text-base">
|
||||
{t('themeStrip.cta')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<ul ref={listRef} aria-label={t('themeStrip.swatches')} className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{PRESETS.map((preset, i) => {
|
||||
const vars = themeToCssVars(preset)
|
||||
const active = preset.id === theme.id
|
||||
return (
|
||||
<motion.li
|
||||
key={preset.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : undefined}
|
||||
transition={{ duration: 0.45, delay: (i % 6) * 0.05 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={tt('apply', { name: preset.name })}
|
||||
onClick={() => setTheme(preset.id, 'user')}
|
||||
className="lv-swatch group"
|
||||
data-active={active}
|
||||
style={vars}
|
||||
>
|
||||
<span className="lv-swatch-header" aria-hidden="true">
|
||||
<span className="lv-swatch-dot" />
|
||||
<span className="lv-swatch-bar" />
|
||||
</span>
|
||||
<span className="lv-swatch-body" aria-hidden="true">
|
||||
<span className="lv-swatch-card" />
|
||||
<span className="lv-swatch-card lv-swatch-card-on" />
|
||||
</span>
|
||||
<span className="lv-swatch-name">{preset.name}</span>
|
||||
</button>
|
||||
</motion.li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
211
frontend/src/features/landing/styles/landing.css
Normal file
211
frontend/src/features/landing/styles/landing.css
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/* Landing-only styling that is awkward as utility classes (layered gradients, glows, keyframes). */
|
||||
|
||||
.lv-bleed {
|
||||
width: 100vw;
|
||||
margin-left: calc(50% - 50vw);
|
||||
}
|
||||
|
||||
.lv-voxel {
|
||||
background: linear-gradient(180deg, #060818 0%, #120f2e 26%, #2b1a4a 46%, #6a2d5c 62%, #c25a55 76%, #f09a5c 88%, #f7b36a 100%);
|
||||
}
|
||||
|
||||
.lv-hero-shade {
|
||||
background:
|
||||
radial-gradient(80% 60% at 20% 45%, rgb(6 9 20 / 0.72), transparent 70%),
|
||||
linear-gradient(180deg, rgb(13 20 22 / 0.35) 0%, transparent 25%, transparent 70%, var(--color-abyss) 100%);
|
||||
}
|
||||
|
||||
.lv-hero-title {
|
||||
font-size: clamp(2.5rem, 5.4vw, 4.6rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
text-shadow: 0 4px 30px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.lv-hero-accent {
|
||||
display: inline-block;
|
||||
color: var(--color-ice);
|
||||
background: linear-gradient(100deg, #fff 0%, var(--color-ice) 45%, var(--color-ice) 70%, #fff 100%);
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 0 22px var(--page-accent-glow));
|
||||
animation: lv-sheen 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes lv-sheen {
|
||||
0%, 100% { background-position: 100% 0; }
|
||||
50% { background-position: 0 0; }
|
||||
}
|
||||
|
||||
.lv-cta-primary {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.4rem;
|
||||
border-radius: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--color-abyss);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--color-ice) 75%, #fff), var(--color-ice));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-ice) 60%, #fff) inset, 0 10px 40px -8px var(--page-accent-glow),
|
||||
0 0 60px -10px var(--page-accent-glow);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
.lv-cta-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 0 1px #fff inset, 0 16px 50px -8px var(--page-accent-glow), 0 0 80px -6px var(--page-accent-glow);
|
||||
}
|
||||
|
||||
.lv-live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ice);
|
||||
box-shadow: 0 0 0 0 var(--color-ice);
|
||||
animation: lv-pulse 2.2s ease-out infinite;
|
||||
}
|
||||
@keyframes lv-pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-ice) 70%, transparent); }
|
||||
100% { box-shadow: 0 0 0 9px transparent; }
|
||||
}
|
||||
|
||||
.lv-fact {
|
||||
background: linear-gradient(180deg, #fff, color-mix(in srgb, var(--color-ice) 70%, #fff));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.lv-theme-now {
|
||||
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
|
||||
border: 1px solid color-mix(in srgb, var(--gui-accent) 35%, transparent);
|
||||
box-shadow: 0 0 40px -12px var(--gui-accent-glow);
|
||||
transition: box-shadow 400ms, border-color 400ms;
|
||||
}
|
||||
.lv-chip {
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background-image: none;
|
||||
transition: background-color 400ms;
|
||||
}
|
||||
|
||||
.lv-swatch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent);
|
||||
background: var(--gui-window-gradient);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0.9, 0.3, 1.2), box-shadow 300ms, border-color 300ms;
|
||||
}
|
||||
.lv-swatch:hover { transform: translateY(-3px); box-shadow: 0 12px 30px -10px var(--gui-accent-glow); }
|
||||
.lv-swatch[data-active="true"] {
|
||||
border-color: var(--gui-accent);
|
||||
box-shadow: 0 0 0 1px var(--gui-accent), 0 0 30px -4px var(--gui-accent-glow);
|
||||
}
|
||||
.lv-swatch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
background: var(--gui-header-gradient);
|
||||
}
|
||||
.lv-swatch-dot { width: 8px; height: 8px; border-radius: 2px; transform: rotate(45deg); background: var(--gui-accent); box-shadow: 0 0 8px var(--gui-accent); }
|
||||
.lv-swatch-bar { height: 4px; width: 38%; border-radius: 4px; background: color-mix(in srgb, var(--gui-text) 35%, transparent); }
|
||||
.lv-swatch-body { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; padding: 7px 8px 0; }
|
||||
.lv-swatch-card { height: 18px; border-radius: 4px; background: var(--gui-surface-hover); border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent); }
|
||||
.lv-swatch-card-on { background: var(--gui-card-gradient); border-color: color-mix(in srgb, var(--gui-accent) 50%, transparent); box-shadow: 0 0 10px -2px var(--gui-accent-glow); }
|
||||
.lv-swatch-name { padding: 7px 9px 8px; font-weight: 600; font-size: 0.9rem; color: var(--gui-text); }
|
||||
|
||||
/* HUD playground: a cropped voxel field with draggable, keyboard-movable widgets. */
|
||||
.lv-hud-field {
|
||||
height: clamp(300px, 46vw, 380px);
|
||||
background: linear-gradient(180deg, #0b0f22, #241a3d 55%, #3a2350);
|
||||
box-shadow: inset 0 0 60px -20px #000;
|
||||
}
|
||||
/* Same glass recipe as the ClickGui window (.lv-gui-window): themed gradient fill,
|
||||
accent border via the border-box trick, backdrop blur and an accent glow. */
|
||||
.lv-hud-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
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 -8px var(--gui-accent-glow, transparent);
|
||||
backdrop-filter: blur(12px) saturate(1.25);
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
}
|
||||
.lv-hud-widget:active { cursor: grabbing; }
|
||||
.lv-hud-widget:focus-visible { outline: 2px solid var(--color-ice); outline-offset: 2px; }
|
||||
|
||||
/* Module wall: each column is a seamless vertical marquee (list is duplicated in the DOM). */
|
||||
.lv-wall-viewport { -webkit-mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); }
|
||||
.lv-wall-track { animation: lv-wall-scroll var(--dur, 40s) linear infinite; will-change: transform; }
|
||||
.lv-wall-col:hover .lv-wall-track { animation-play-state: paused; }
|
||||
@keyframes lv-wall-scroll {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-50%); }
|
||||
}
|
||||
|
||||
.lv-example-tag {
|
||||
font-family: var(--font-code, monospace);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-frost, #9fb0c0);
|
||||
border: 1px solid rgb(255 255 255 / 0.15);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.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; }
|
||||
.lv-faq-item[open] .lv-faq-marker { transform: rotate(225deg); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lv-wall-track { animation: none; }
|
||||
}
|
||||
|
||||
/* Download page: numbered install steps and safe Markdown changelog styling. */
|
||||
.lv-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-ice) 18%, transparent);
|
||||
color: var(--color-ice, #6cf);
|
||||
font-family: var(--font-code, monospace);
|
||||
font-weight: 700;
|
||||
}
|
||||
.lv-markdown :where(h1, h2, h3) { font-weight: 700; color: var(--color-snow, #fff); margin: 0.6em 0 0.3em; }
|
||||
.lv-markdown :where(p, ul, ol) { margin: 0.4em 0; }
|
||||
.lv-markdown :where(ul, ol) { padding-left: 1.3em; }
|
||||
.lv-markdown :where(li) { margin: 0.15em 0; }
|
||||
.lv-markdown :where(code) { font-family: var(--font-code, monospace); background: rgb(255 255 255 / 0.08); padding: 0.1em 0.4em; border-radius: 4px; }
|
||||
.lv-markdown :where(a) { color: var(--color-ice, #6cf); text-decoration: underline; text-underline-offset: 3px; }
|
||||
41
frontend/src/features/landing/tests/landing.test.tsx
Normal file
41
frontend/src/features/landing/tests/landing.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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 { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { SessionProvider } from '../../auth/session'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('reduced motion shows the full command at once', async () => {
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
const router = createMemoryRouter([{ path: '/', element: <CommandHero /> }])
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SessionProvider><RouterProvider router={router} /></SessionProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(await screen.findByText('%config load 7KQ3M9XA')).toBeInTheDocument()
|
||||
expect(screen.getByText('[LoVisual] Конфиг «pvp» загружен')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Пример: загрузка конфига по коду в чате игры')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('english locale renders the landing hero headline', async () => {
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
const i18n = initI18n('en')
|
||||
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent('you can see')
|
||||
})
|
||||
68
frontend/src/features/landing/tests/sections.test.tsx
Normal file
68
frontend/src/features/landing/tests/sections.test.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
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'
|
||||
|
||||
const reduceMotion = () =>
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
|
||||
function withQuery(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const router = createMemoryRouter([{ path: '/', element: ui }])
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
test('HudWidget moves by 8px with ArrowRight', () => {
|
||||
reduceMotion()
|
||||
withQuery(<HudPlayground />)
|
||||
const widget = screen.getByRole('button', { name: /FPS/ })
|
||||
const before = Number.parseInt(widget.style.left, 10)
|
||||
fireEvent.keyDown(widget, { key: 'ArrowRight' })
|
||||
expect(Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)).toBe(before + 8)
|
||||
})
|
||||
|
||||
test('HudWidget stays inside the playfield bounds', () => {
|
||||
reduceMotion()
|
||||
withQuery(<HudPlayground />)
|
||||
for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
|
||||
const left = Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)
|
||||
expect(left).toBeGreaterThanOrEqual(0)
|
||||
expect(left).toBeLessThanOrEqual(680)
|
||||
})
|
||||
|
||||
test('ModuleWall lists all four category headings', () => {
|
||||
reduceMotion()
|
||||
withQuery(<ModuleWall />)
|
||||
for (const name of ['Combat', 'Visuals', 'Player', 'Misc']) {
|
||||
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
test('ShowcaseTeaser falls back to example cards on fetch error', async () => {
|
||||
reduceMotion()
|
||||
mockFetch({}) // no /showcase route -> 404 -> query error
|
||||
withQuery(<ShowcaseTeaser />)
|
||||
const tags = await screen.findAllByText('Пример')
|
||||
expect(tags).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('ParticleField schedules no animation frame under reduced motion', () => {
|
||||
const ctx = new Proxy({}, { get: () => () => ({ addColorStop() {} }), set: () => true })
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
reduceMotion()
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
render(<ParticleField color="rgb(10, 20, 30)" />)
|
||||
expect(raf).not.toHaveBeenCalled()
|
||||
;(HTMLCanvasElement.prototype.getContext as unknown as { mockRestore: () => void }).mockRestore()
|
||||
})
|
||||
70
frontend/src/features/themes/editor/ColorField.tsx
Normal file
70
frontend/src/features/themes/editor/ColorField.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useId, useState } from 'react'
|
||||
import { parseArgb } from '../types'
|
||||
|
||||
type Props = { label: string; value: string; onChange: (hex: string) => void }
|
||||
|
||||
const pad2 = (n: number) => n.toString(16).padStart(2, '0')
|
||||
const toRgb6 = (hex: string) => {
|
||||
const { r, g, b } = parseArgb(hex)
|
||||
return `#${pad2(r)}${pad2(g)}${pad2(b)}`.toUpperCase()
|
||||
}
|
||||
/** Rebuild `#AARRGGBB` from a `#RRGGBB` picker value, keeping the current alpha. */
|
||||
const withAlpha = (hex: string, rgb6: string) => `#${pad2(parseArgb(hex).a)}${rgb6.slice(1)}`.toUpperCase()
|
||||
/** Rebuild `#AARRGGBB` from an alpha (0–255), keeping the current rgb. */
|
||||
const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase()
|
||||
|
||||
/** One editable palette slot: native color picker + alpha slider + hex field. */
|
||||
export function ColorField({ label, value, onChange }: Props) {
|
||||
const id = useId()
|
||||
// Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes
|
||||
// for another reason (React's "adjust state during render" pattern).
|
||||
const [editing, setEditing] = useState<{ value: string; text: string }>({ value, text: value })
|
||||
const text = editing.value === value ? editing.text : value
|
||||
|
||||
const commitText = () => {
|
||||
const t = text.trim()
|
||||
if (/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) onChange(`#${t.replace(/^#/, '').toUpperCase()}`)
|
||||
else setEditing({ value, text: value })
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<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">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={label}
|
||||
value={toRgb6(value)}
|
||||
onChange={(e) => onChange(withAlpha(value, e.target.value))}
|
||||
className="size-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
aria-label={`${label} alpha`}
|
||||
value={parseArgb(value).a}
|
||||
onChange={(e) => onChange(withRgb(value, Number(e.target.value)))}
|
||||
className="lv-alpha-slider h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
value={text}
|
||||
onChange={(e) => setEditing({ value, text: e.target.value })}
|
||||
onBlur={commitText}
|
||||
onKeyDown={(e) => e.key === 'Enter' && commitText()}
|
||||
className="lv-hex-input w-[6.25rem] shrink-0 rounded-md border border-white/15 bg-black/40 px-2 py-1 font-code text-xs text-snow"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
28
frontend/src/features/themes/editor/EditorPreview.tsx
Normal file
28
frontend/src/features/themes/editor/EditorPreview.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useMemo } from 'react'
|
||||
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
|
||||
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 }) {
|
||||
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">
|
||||
<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)' }}>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
frontend/src/features/themes/editor/GradientField.tsx
Normal file
47
frontend/src/features/themes/editor/GradientField.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { GradientSpec } from '../types'
|
||||
import { ColorField } from './ColorField'
|
||||
|
||||
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
|
||||
|
||||
/** A gradient slot: enable toggle, start/end colors and a 0–360° angle control. */
|
||||
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">
|
||||
<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">
|
||||
{t('editor.enabled')}
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${title} · ${t('editor.enabled')}`}
|
||||
checked={value.enabled}
|
||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||
className="size-4 accent-[var(--color-ice)]"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<ColorField label={t('editor.start')} value={value.start} onChange={(start) => onChange({ start })} />
|
||||
{value.enabled && (
|
||||
<>
|
||||
<ColorField label={t('editor.end')} value={value.end} onChange={(end) => onChange({ end })} />
|
||||
<label className="flex items-center gap-2 text-xs text-frost">
|
||||
<span className="w-12 shrink-0">{t('editor.angle')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={359}
|
||||
value={Math.round(value.angle)}
|
||||
onChange={(e) => onChange({ angle: Number(e.target.value) })}
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right font-code text-snow">{Math.round(value.angle)}°</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
frontend/src/features/themes/editor/ThemeEditor.tsx
Normal file
155
frontend/src/features/themes/editor/ThemeEditor.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { 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'
|
||||
|
||||
/** 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 }) {
|
||||
const { t } = useTranslation('themes')
|
||||
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const flash = useCallback((text: string) => {
|
||||
setNotice(text)
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setNotice(null), 2000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) redo()
|
||||
else undo()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [undo, redo])
|
||||
|
||||
const setColor = (slot: keyof ThemeColors, hex: string) => set({ ...draft, theme: { ...draft.theme, [slot]: hex } })
|
||||
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
|
||||
set({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } })
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard?.writeText(toProfileJson(draft))
|
||||
flash(t('editor.copied'))
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([toProfileJson(draft)], { type: 'application/json' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${draft.id}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const share = async () => {
|
||||
await navigator.clipboard?.writeText(`${location.origin}${location.pathname}#t=${toShareHash(draft)}`)
|
||||
flash(t('editor.shared'))
|
||||
}
|
||||
|
||||
const onFile = (file?: File) => {
|
||||
if (!file) return
|
||||
file.text().then((text) => {
|
||||
const result = fromProfileJson(text)
|
||||
if ('error' in result) flash(t('editor.importError'))
|
||||
else reset(result)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<EditorPreview draft={draft} />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
|
||||
{t('editor.undo')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={redo} disabled={!canRedo}>
|
||||
{t('editor.redo')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={() => reset(initial)}>
|
||||
{t('editor.reset')}
|
||||
</Button>
|
||||
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-frost">{t('editor.nameLabel')}</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => set({ ...draft, name: e.target.value, id: nameToId(e.target.value) })}
|
||||
className="rounded-md border border-white/15 bg-black/40 px-3 py-2 text-snow"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.presets')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => reset(p)}
|
||||
className={`lv-preset-chip ${p.id === draft.id ? 'is-active' : ''}`}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
{COLOR_SLOTS.map((slot) => (
|
||||
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.gradients')}</h3>
|
||||
<div className="grid gap-3">
|
||||
{GRADIENT_SLOTS.map((g) => (
|
||||
<GradientField
|
||||
key={g.prefix}
|
||||
title={t(`editor.gradient.${g.prefix}`)}
|
||||
value={draft[g.key] as GradientSpec}
|
||||
onChange={(patch) => setGradient(g.key, patch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={copy}>{t('editor.copy')}</Button>
|
||||
<Button variant="quiet" onClick={download}>
|
||||
{t('editor.download')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={() => fileRef.current?.click()}>
|
||||
{t('editor.import')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={share}>
|
||||
{t('editor.share')}
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept="application/json,.json" className="hidden" onChange={(e) => onFile(e.target.files?.[0])} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
frontend/src/features/themes/editor/codec.ts
Normal file
138
frontend/src/features/themes/editor/codec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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`.
|
||||
// See mod ThemesProfileCodec.toProfileValues — keep these lists in lockstep.
|
||||
const COLOR_KEYS: (keyof ThemeColors)[] = [
|
||||
'windowBg',
|
||||
'windowHeader',
|
||||
'windowStroke',
|
||||
'surface',
|
||||
'surfaceHover',
|
||||
'cardEnabled',
|
||||
'cardDisabled',
|
||||
'textPrimary',
|
||||
'textMuted',
|
||||
'accent',
|
||||
'accentSoft',
|
||||
'strokeSoft',
|
||||
]
|
||||
|
||||
/** The 12 editable palette slots, in the mod's profile order. */
|
||||
export const COLOR_SLOTS = COLOR_KEYS
|
||||
|
||||
/** The 5 gradient slots: profile prefix + the matching `ThemeEntry` field. */
|
||||
export const GRADIENT_SLOTS = [
|
||||
{ prefix: 'window', key: 'windowGradient' },
|
||||
{ prefix: 'header', key: 'headerGradient' },
|
||||
{ prefix: 'surface', key: 'surfaceGradient' },
|
||||
{ prefix: 'card', key: 'cardGradient' },
|
||||
{ prefix: 'stroke', key: 'strokeGradient' },
|
||||
] as const satisfies { prefix: string; key: keyof ThemeEntry }[]
|
||||
|
||||
const GRADIENT_KEYS: [string, keyof ThemeEntry][] = [
|
||||
['window', 'windowGradient'],
|
||||
['header', 'headerGradient'],
|
||||
['surface', 'surfaceGradient'],
|
||||
['card', 'cardGradient'],
|
||||
['stroke', 'strokeGradient'],
|
||||
]
|
||||
|
||||
const CLASSIC = PRESETS[0]
|
||||
|
||||
type Profile = Record<string, string | boolean | number>
|
||||
|
||||
const upper = (hex: string) => hex.toUpperCase()
|
||||
|
||||
/** Pack an entry into the mod's flat profile object. */
|
||||
export function toProfile(entry: ThemeEntry): Profile {
|
||||
const out: Profile = { name: entry.name }
|
||||
for (const k of COLOR_KEYS) out[k] = upper(entry.theme[k])
|
||||
for (const [prefix, key] of GRADIENT_KEYS) {
|
||||
const g = entry[key] as GradientSpec
|
||||
out[`${prefix}GradientEnabled`] = g.enabled
|
||||
out[`${prefix}GradientStart`] = upper(g.start)
|
||||
out[`${prefix}GradientEnd`] = upper(g.end)
|
||||
out[`${prefix}GradientAngle`] = g.angle
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The mod's profile as an ordered JSON string (human-editable, keys match the mod exactly). */
|
||||
export function toProfileJson(entry: ThemeEntry): string {
|
||||
return JSON.stringify(toProfile(entry), null, 2)
|
||||
}
|
||||
|
||||
const str = (v: unknown, fallback: string) => (typeof v === 'string' && v ? upper(v) : fallback)
|
||||
const rawStr = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v : fallback)
|
||||
const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
|
||||
const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
|
||||
|
||||
function readGradient(map: Profile, prefix: string, fallback: GradientSpec): GradientSpec {
|
||||
return {
|
||||
enabled: bool(map[`${prefix}GradientEnabled`], fallback.enabled),
|
||||
start: str(map[`${prefix}GradientStart`], fallback.start),
|
||||
end: str(map[`${prefix}GradientEnd`], fallback.end),
|
||||
angle: num(map[`${prefix}GradientAngle`], fallback.angle),
|
||||
}
|
||||
}
|
||||
|
||||
/** Slug matching the mod's `uniqueCustomId` spirit: lowercase, non-alphanumeric → `_`. */
|
||||
const slug = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
|
||||
|
||||
function fromProfile(map: Profile): ThemeEntry {
|
||||
const theme = Object.fromEntries(
|
||||
COLOR_KEYS.map((k) => [k, str(map[k], CLASSIC.theme[k])]),
|
||||
) as unknown as ThemeColors
|
||||
const name = rawStr(map.name, 'Custom theme')
|
||||
return {
|
||||
id: slug(name),
|
||||
name,
|
||||
builtin: false,
|
||||
theme,
|
||||
windowGradient: readGradient(map, 'window', CLASSIC.windowGradient),
|
||||
headerGradient: readGradient(map, 'header', CLASSIC.headerGradient),
|
||||
surfaceGradient: readGradient(map, 'surface', CLASSIC.surfaceGradient),
|
||||
cardGradient: readGradient(map, 'card', CLASSIC.cardGradient),
|
||||
strokeGradient: readGradient(map, 'stroke', CLASSIC.strokeGradient),
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an entry from JSON. Tolerant: missing keys fall back to Classic; bad input → error. */
|
||||
export function fromProfileJson(json: string): ThemeEntry | { error: string } {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(json)
|
||||
} catch {
|
||||
return { error: 'invalidJson' }
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { error: 'invalidShape' }
|
||||
return fromProfile(parsed as Profile)
|
||||
}
|
||||
|
||||
// base64url without padding, so the share hash is URL-fragment safe.
|
||||
function b64url(text: string): string {
|
||||
const b64 = btoa(unescape(encodeURIComponent(text)))
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function unb64url(b64: string): string {
|
||||
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : ''
|
||||
const std = b64.replace(/-/g, '+').replace(/_/g, '/') + pad
|
||||
return decodeURIComponent(escape(atob(std)))
|
||||
}
|
||||
|
||||
/** Compact profile → URL-safe hash for the `#t=` share link. */
|
||||
export function toShareHash(entry: ThemeEntry): string {
|
||||
return b64url(JSON.stringify(toProfile(entry)))
|
||||
}
|
||||
|
||||
/** Decode a `#t=` hash back into an entry (or an error shape). */
|
||||
export function fromShareHash(hash: string): ThemeEntry | { error: string } {
|
||||
try {
|
||||
return fromProfileJson(unb64url(hash))
|
||||
} catch {
|
||||
return { error: 'invalidHash' }
|
||||
}
|
||||
}
|
||||
48
frontend/src/features/themes/editor/editor.css
Normal file
48
frontend/src/features/themes/editor/editor.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
|
||||
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
|
||||
|
||||
.lv-hud-tile {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft, #6cf) 40%, transparent);
|
||||
color: var(--gui-text, #fff);
|
||||
font-family: var(--font-code, monospace);
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 6px 20px -12px #000;
|
||||
}
|
||||
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
|
||||
|
||||
.lv-preset-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgb(255 255 255 / 0.15);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: var(--color-frost, #9fb0c0);
|
||||
font-size: 0.8rem;
|
||||
transition: border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
.lv-preset-chip:hover { border-color: color-mix(in srgb, var(--color-ice, #6cf) 50%, transparent); color: var(--color-snow, #fff); }
|
||||
.lv-preset-chip.is-active { border-color: var(--color-ice, #6cf); color: var(--color-ice, #6cf); }
|
||||
|
||||
/* Checkerboard behind the swatch so partial alpha is visible. */
|
||||
.lv-alpha-check {
|
||||
background-color: #20242b;
|
||||
background-image:
|
||||
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%),
|
||||
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%);
|
||||
background-size: 10px 10px;
|
||||
background-position: 0 0, 5px 5px;
|
||||
}
|
||||
|
||||
.lv-alpha-slider::-webkit-slider-thumb,
|
||||
.lv-alpha-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ice, #6cf);
|
||||
border: 2px solid #0b0f22;
|
||||
cursor: pointer;
|
||||
}
|
||||
85
frontend/src/features/themes/editor/tests/codec.test.ts
Normal file
85
frontend/src/features/themes/editor/tests/codec.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec'
|
||||
|
||||
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
|
||||
const PROFILE_KEYS = [
|
||||
'name',
|
||||
'windowBg',
|
||||
'windowHeader',
|
||||
'windowStroke',
|
||||
'surface',
|
||||
'surfaceHover',
|
||||
'cardEnabled',
|
||||
'cardDisabled',
|
||||
'textPrimary',
|
||||
'textMuted',
|
||||
'accent',
|
||||
'accentSoft',
|
||||
'strokeSoft',
|
||||
'windowGradientEnabled',
|
||||
'windowGradientStart',
|
||||
'windowGradientEnd',
|
||||
'windowGradientAngle',
|
||||
'headerGradientEnabled',
|
||||
'headerGradientStart',
|
||||
'headerGradientEnd',
|
||||
'headerGradientAngle',
|
||||
'surfaceGradientEnabled',
|
||||
'surfaceGradientStart',
|
||||
'surfaceGradientEnd',
|
||||
'surfaceGradientAngle',
|
||||
'cardGradientEnabled',
|
||||
'cardGradientStart',
|
||||
'cardGradientEnd',
|
||||
'cardGradientAngle',
|
||||
'strokeGradientEnabled',
|
||||
'strokeGradientStart',
|
||||
'strokeGradientEnd',
|
||||
'strokeGradientAngle',
|
||||
]
|
||||
|
||||
const editable = (e: (typeof PRESETS)[number]) => ({
|
||||
name: e.name,
|
||||
theme: e.theme,
|
||||
windowGradient: e.windowGradient,
|
||||
headerGradient: e.headerGradient,
|
||||
surfaceGradient: e.surfaceGradient,
|
||||
cardGradient: e.cardGradient,
|
||||
strokeGradient: e.strokeGradient,
|
||||
})
|
||||
|
||||
describe('theme profile codec', () => {
|
||||
test('JSON keys match the mod profile exactly', () => {
|
||||
expect(Object.keys(JSON.parse(toProfileJson(PRESETS[0])))).toEqual(PROFILE_KEYS)
|
||||
})
|
||||
|
||||
test('round-trips every preset through the profile JSON', () => {
|
||||
for (const preset of PRESETS) {
|
||||
const result = fromProfileJson(toProfileJson(preset))
|
||||
expect('error' in result).toBe(false)
|
||||
if (!('error' in result)) expect(editable(result)).toEqual(editable(preset))
|
||||
}
|
||||
})
|
||||
|
||||
test('share hash round-trips', () => {
|
||||
for (const preset of PRESETS) {
|
||||
const result = fromShareHash(toShareHash(preset))
|
||||
if ('error' in result) throw new Error('unexpected hash error')
|
||||
expect(editable(result)).toEqual(editable(preset))
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects malformed JSON and non-objects', () => {
|
||||
expect(fromProfileJson('{ nope')).toEqual({ error: 'invalidJson' })
|
||||
expect(fromProfileJson('[1,2,3]')).toEqual({ error: 'invalidShape' })
|
||||
expect(fromShareHash('%%%not-base64%%%')).toEqual({ error: 'invalidHash' })
|
||||
})
|
||||
|
||||
test('is tolerant: missing keys fall back to Classic', () => {
|
||||
const result = fromProfileJson(JSON.stringify({ name: 'Sparse' }))
|
||||
if ('error' in result) throw new Error('unexpected error')
|
||||
expect(result.name).toBe('Sparse')
|
||||
expect(result.theme).toEqual(PRESETS[0].theme)
|
||||
})
|
||||
})
|
||||
18
frontend/src/features/themes/editor/tests/editor.test.tsx
Normal file
18
frontend/src/features/themes/editor/tests/editor.test.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { ThemeEditor } from '../ThemeEditor'
|
||||
|
||||
test('editor shows the initial preset name and one toggle per gradient slot', () => {
|
||||
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||
const name = screen.getByDisplayValue('Classic')
|
||||
expect(name).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('checkbox', { name: /Градиент/ })).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('picking a preset chip loads it into the editor', () => {
|
||||
const next = PRESETS[1]
|
||||
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: next.name }))
|
||||
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
|
||||
})
|
||||
85
frontend/src/features/themes/editor/useThemeHistory.ts
Normal file
85
frontend/src/features/themes/editor/useThemeHistory.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { ThemeEntry } from '../types'
|
||||
|
||||
const LIMIT = 50
|
||||
/** Changes closer than this share one undo step, so a slider drag is a single revert. */
|
||||
const COALESCE_MS = 500
|
||||
|
||||
export type ThemeHistory = {
|
||||
draft: ThemeEntry
|
||||
/** Live edit; coalesced with recent edits of the same interaction. */
|
||||
set: (next: ThemeEntry) => void
|
||||
/** Replace the draft and clear history (preset pick, import, share-link load). */
|
||||
reset: (next: ThemeEntry) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
}
|
||||
|
||||
/** Undo/redo ring buffer (max 50 steps) for the theme editor draft. */
|
||||
export function useThemeHistory(initial: ThemeEntry): ThemeHistory {
|
||||
const [present, setPresent] = useState(initial)
|
||||
const presentRef = useRef(present)
|
||||
const past = useRef<ThemeEntry[]>([])
|
||||
const future = useRef<ThemeEntry[]>([])
|
||||
const lastEdit = useRef(0)
|
||||
const [flags, setFlags] = useState({ canUndo: false, canRedo: false })
|
||||
|
||||
const commit = useCallback((next: ThemeEntry) => {
|
||||
presentRef.current = next
|
||||
setPresent(next)
|
||||
setFlags({ canUndo: past.current.length > 0, canRedo: future.current.length > 0 })
|
||||
}, [])
|
||||
|
||||
const set = useCallback(
|
||||
(next: ThemeEntry) => {
|
||||
const now = Date.now()
|
||||
if (now - lastEdit.current > COALESCE_MS) {
|
||||
past.current = [...past.current, presentRef.current].slice(-LIMIT)
|
||||
future.current = []
|
||||
}
|
||||
lastEdit.current = now
|
||||
commit(next)
|
||||
},
|
||||
[commit],
|
||||
)
|
||||
|
||||
const reset = useCallback(
|
||||
(next: ThemeEntry) => {
|
||||
past.current = []
|
||||
future.current = []
|
||||
lastEdit.current = 0
|
||||
commit(next)
|
||||
},
|
||||
[commit],
|
||||
)
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (past.current.length === 0) return
|
||||
future.current = [presentRef.current, ...future.current].slice(0, LIMIT)
|
||||
lastEdit.current = 0
|
||||
commit(past.current.at(-1) as ThemeEntry)
|
||||
past.current = past.current.slice(0, -1)
|
||||
setFlags({ canUndo: past.current.length > 0, canRedo: true })
|
||||
}, [commit])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (future.current.length === 0) return
|
||||
past.current = [...past.current, presentRef.current].slice(-LIMIT)
|
||||
lastEdit.current = 0
|
||||
commit(future.current[0] as ThemeEntry)
|
||||
future.current = future.current.slice(1)
|
||||
setFlags({ canUndo: true, canRedo: future.current.length > 0 })
|
||||
}, [commit])
|
||||
|
||||
return {
|
||||
draft: present,
|
||||
set,
|
||||
reset,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: flags.canUndo,
|
||||
canRedo: flags.canRedo,
|
||||
}
|
||||
}
|
||||
49
frontend/src/features/themes/i18n/en.ts
Normal file
49
frontend/src/features/themes/i18n/en.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { themesRu } from './ru'
|
||||
|
||||
export const themesEn = {
|
||||
apply: 'Apply the {{name}} theme',
|
||||
colors: {
|
||||
windowBg: 'Window',
|
||||
windowHeader: 'Header',
|
||||
windowStroke: 'Stroke',
|
||||
surface: 'Surface',
|
||||
surfaceHover: 'Hover',
|
||||
cardEnabled: 'Card on',
|
||||
cardDisabled: 'Card off',
|
||||
textPrimary: 'Text',
|
||||
textMuted: 'Muted text',
|
||||
accent: 'Accent',
|
||||
accentSoft: 'Soft accent',
|
||||
strokeSoft: 'Soft stroke',
|
||||
},
|
||||
editor: {
|
||||
title: 'Theme editor',
|
||||
lead: 'Build your own theme and bring it into the mod as a single file.',
|
||||
nameLabel: 'Theme name',
|
||||
presets: 'Ready presets',
|
||||
colors: 'Colors',
|
||||
gradients: 'Gradients',
|
||||
enabled: 'Gradient',
|
||||
angle: 'Angle',
|
||||
start: 'Start',
|
||||
end: 'End',
|
||||
gradient: {
|
||||
window: 'Window',
|
||||
header: 'Header',
|
||||
surface: 'Surface',
|
||||
card: 'Card',
|
||||
stroke: 'Stroke',
|
||||
},
|
||||
copy: 'Copy for the mod',
|
||||
copied: 'Copied',
|
||||
download: 'Download .json',
|
||||
import: 'Import from file',
|
||||
share: 'Share link',
|
||||
shared: 'Link copied',
|
||||
reset: 'Reset',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
importError: 'Could not read the theme — the file is malformed.',
|
||||
},
|
||||
} satisfies Translation<typeof themesRu>
|
||||
46
frontend/src/features/themes/i18n/ru.ts
Normal file
46
frontend/src/features/themes/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export const themesRu = {
|
||||
apply: 'Применить тему «{{name}}»',
|
||||
colors: {
|
||||
windowBg: 'Фон окна',
|
||||
windowHeader: 'Шапка',
|
||||
windowStroke: 'Обводка',
|
||||
surface: 'Поверхность',
|
||||
surfaceHover: 'Наведение',
|
||||
cardEnabled: 'Карточка вкл.',
|
||||
cardDisabled: 'Карточка выкл.',
|
||||
textPrimary: 'Текст',
|
||||
textMuted: 'Приглушённый текст',
|
||||
accent: 'Акцент',
|
||||
accentSoft: 'Мягкий акцент',
|
||||
strokeSoft: 'Мягкая обводка',
|
||||
},
|
||||
editor: {
|
||||
title: 'Редактор тем',
|
||||
lead: 'Собери свою тему и перенеси её в мод одним файлом.',
|
||||
nameLabel: 'Название темы',
|
||||
presets: 'Готовые пресеты',
|
||||
colors: 'Цвета',
|
||||
gradients: 'Градиенты',
|
||||
enabled: 'Градиент',
|
||||
angle: 'Угол',
|
||||
start: 'Начало',
|
||||
end: 'Конец',
|
||||
gradient: {
|
||||
window: 'Окно',
|
||||
header: 'Шапка',
|
||||
surface: 'Поверхность',
|
||||
card: 'Карточка',
|
||||
stroke: 'Обводка',
|
||||
},
|
||||
copy: 'Скопировать для мода',
|
||||
copied: 'Скопировано',
|
||||
download: 'Скачать .json',
|
||||
import: 'Импорт из файла',
|
||||
share: 'Ссылка на тему',
|
||||
shared: 'Ссылка скопирована',
|
||||
reset: 'Сбросить',
|
||||
undo: 'Отменить',
|
||||
redo: 'Вернуть',
|
||||
importError: 'Не удалось прочитать тему — файл испорчен.',
|
||||
},
|
||||
} as const
|
||||
438
frontend/src/features/themes/presets.generated.ts
Normal file
438
frontend/src/features/themes/presets.generated.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
// generated — do not edit. Source: mod/.../features/theme/presets/ThemePresets{1,2,3}.java
|
||||
// Regenerate with `bun run gen:themes`.
|
||||
import type { ThemeEntry } from './types'
|
||||
|
||||
export const PRESETS: ThemeEntry[] = [
|
||||
{
|
||||
id: 'classic',
|
||||
name: 'Classic',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F012191B',
|
||||
windowHeader: '#F0142226',
|
||||
windowStroke: '#60203030',
|
||||
surface: '#16202523',
|
||||
surfaceHover: '#28353A3E',
|
||||
cardEnabled: '#5043A7C8',
|
||||
cardDisabled: '#00334046',
|
||||
textPrimary: '#FFFFFFFF',
|
||||
textMuted: '#88C6D2CD',
|
||||
accent: '#FF5CC8E7',
|
||||
accentSoft: '#805CC8E7',
|
||||
strokeSoft: '#60FFFFFF',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F012191B', end: '#F012191B', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#16202523', end: '#16202523', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#5043A7C8', end: '#5043A7C8', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60203030', end: '#60203030', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F0121314',
|
||||
windowHeader: '#F0000205',
|
||||
windowStroke: '#E1121314',
|
||||
surface: '#FF171819',
|
||||
surfaceHover: '#FF131315',
|
||||
cardEnabled: '#503F464D',
|
||||
cardDisabled: '#33191C20',
|
||||
textPrimary: '#FFF5F5FF',
|
||||
textMuted: '#88A5A5A5',
|
||||
accent: '#FF767C94',
|
||||
accentSoft: '#80767C94',
|
||||
strokeSoft: '#60373746',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F0121314', end: '#F0121314', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0141619', end: '#F0000205', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF171819', end: '#FF171819', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#503F464D', end: '#503F464D', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#E1121314', end: '#E1121314', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'amber',
|
||||
name: 'Amber',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F019120D',
|
||||
windowHeader: '#F0201711',
|
||||
windowStroke: '#60C3652C',
|
||||
surface: '#FF221711',
|
||||
surfaceHover: '#FF2B1E16',
|
||||
cardEnabled: '#50E08C3A',
|
||||
cardDisabled: '#40302620',
|
||||
textPrimary: '#FFFFF7EB',
|
||||
textMuted: '#88E3C9A7',
|
||||
accent: '#FFE69A43',
|
||||
accentSoft: '#80E69A43',
|
||||
strokeSoft: '#60F5D7B0',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F019120D', end: '#F019120D', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0322A24', end: '#F01E1610', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF221711', end: '#FF221711', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#50E08C3A', end: '#50E08C3A', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60C3652C', end: '#60C3652C', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'noir',
|
||||
name: 'Noir',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F00C0D10',
|
||||
windowHeader: '#F0121317',
|
||||
windowStroke: '#60393F4A',
|
||||
surface: '#FF14161B',
|
||||
surfaceHover: '#FF1C1F26',
|
||||
cardEnabled: '#5039424F',
|
||||
cardDisabled: '#40282D35',
|
||||
textPrimary: '#FFF4F4F4',
|
||||
textMuted: '#88C7CBD3',
|
||||
accent: '#FFE16B78',
|
||||
accentSoft: '#80E16B78',
|
||||
strokeSoft: '#605A6370',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F00C0D10', end: '#F00C0D10', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F025262A', end: '#F0111216', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF14161B', end: '#FF14161B', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#5039424F', end: '#5039424F', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60393F4A', end: '#60393F4A', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'purple',
|
||||
name: 'Purple',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F0120E1A',
|
||||
windowHeader: '#F0181323',
|
||||
windowStroke: '#60433A5A',
|
||||
surface: '#FF1C1627',
|
||||
surfaceHover: '#FF261D35',
|
||||
cardEnabled: '#505E4AA0',
|
||||
cardDisabled: '#40312644',
|
||||
textPrimary: '#FFF5F2FF',
|
||||
textMuted: '#88CFC6E6',
|
||||
accent: '#FF9B6BFF',
|
||||
accentSoft: '#809B6BFF',
|
||||
strokeSoft: '#60705A9C',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F0120E1A', end: '#F0120E1A', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F02A2635', end: '#F0171221', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF1C1627', end: '#FF1C1627', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#505E4AA0', end: '#505E4AA0', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60433A5A', end: '#60433A5A', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'blue',
|
||||
name: 'Blue',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F0080F1E',
|
||||
windowHeader: '#F00D1626',
|
||||
windowStroke: '#60405A82',
|
||||
surface: '#FF101B2B',
|
||||
surfaceHover: '#FF162338',
|
||||
cardEnabled: '#50406BB3',
|
||||
cardDisabled: '#4023324A',
|
||||
textPrimary: '#FFF0F5FF',
|
||||
textMuted: '#88B2C4E6',
|
||||
accent: '#FF2E64CC',
|
||||
accentSoft: '#803A6FD6',
|
||||
strokeSoft: '#60465F8E',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F0080F1E', end: '#F0080F1E', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0202937', end: '#F00C1524', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF101B2B', end: '#FF101B2B', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#50406BB3', end: '#50406BB3', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60405A82', end: '#60405A82', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
name: 'Azure',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F00C1220',
|
||||
windowHeader: '#F0101A2A',
|
||||
windowStroke: '#60324B66',
|
||||
surface: '#FF121C2C',
|
||||
surfaceHover: '#FF18253A',
|
||||
cardEnabled: '#503F6BAA',
|
||||
cardDisabled: '#40283348',
|
||||
textPrimary: '#FFF2F7FF',
|
||||
textMuted: '#88BBD0EA',
|
||||
accent: '#FF5DA7FF',
|
||||
accentSoft: '#805DA7FF',
|
||||
strokeSoft: '#60507CA8',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F00C1220', end: '#F00C1220', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0232C3B', end: '#F00F1827', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF121C2C', end: '#FF121C2C', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#503F6BAA', end: '#503F6BAA', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60324B66', end: '#60324B66', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'nitro',
|
||||
name: 'Nitro',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF121624',
|
||||
windowHeader: '#FF181E33',
|
||||
windowStroke: '#60465580',
|
||||
surface: '#FF1A1F36',
|
||||
surfaceHover: '#FF232A45',
|
||||
cardEnabled: '#50A24CFF',
|
||||
cardDisabled: '#40303A52',
|
||||
textPrimary: '#FFF3F6FF',
|
||||
textMuted: '#88C7D3F3',
|
||||
accent: '#FF7A5CFF',
|
||||
accentSoft: '#807A5CFF',
|
||||
strokeSoft: '#60829AC9',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF36205F', end: '#FF0B7DFF', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF4B2FAF', end: '#FF24C1FF', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1A1F36', end: '#FF121728', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF5A37D5', end: '#FF2AD1FF', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF6C4BFF', end: '#FF4BD0FF', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
name: 'Sunset',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF1A1116',
|
||||
windowHeader: '#FF211319',
|
||||
windowStroke: '#605C2B2B',
|
||||
surface: '#FF24151C',
|
||||
surfaceHover: '#FF2E1A23',
|
||||
cardEnabled: '#50FF7A45',
|
||||
cardDisabled: '#40261A1A',
|
||||
textPrimary: '#FFFFF2E8',
|
||||
textMuted: '#88E3B9A3',
|
||||
accent: '#FFFF7A45',
|
||||
accentSoft: '#80FF7A45',
|
||||
strokeSoft: '#60F5C1A6',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2A1020', end: '#FFB2431F', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF3B1626', end: '#FFC25B2A', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF24151C', end: '#FF1E1116', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFFF8A4C', end: '#FFFF4B7A', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFFFB27A', end: '#FFFF6A3A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'aurora',
|
||||
name: 'Aurora',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF0D1A18',
|
||||
windowHeader: '#FF102320',
|
||||
windowStroke: '#602B5B57',
|
||||
surface: '#FF132623',
|
||||
surfaceHover: '#FF1A312D',
|
||||
cardEnabled: '#5034D399',
|
||||
cardDisabled: '#40304742',
|
||||
textPrimary: '#FFF1FFF9',
|
||||
textMuted: '#8897E3D0',
|
||||
accent: '#FF2DD9C3',
|
||||
accentSoft: '#802DD9C3',
|
||||
strokeSoft: '#6071B5A6',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF0B2A3C', end: '#FF2A8E6B', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF0E3B46', end: '#FF1FB88B', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF132623', end: '#FF10201D', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF2DD9C3', end: '#FF5A7CFF', angle: 135 },
|
||||
strokeGradient: { enabled: true, start: '#FF78FFE5', end: '#FF6FB1FF', angle: 120 },
|
||||
},
|
||||
{
|
||||
id: 'mint',
|
||||
name: 'Mint',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF0E1917',
|
||||
windowHeader: '#FF112320',
|
||||
windowStroke: '#60255B53',
|
||||
surface: '#FF132521',
|
||||
surfaceHover: '#FF1A2F2B',
|
||||
cardEnabled: '#5035CFA8',
|
||||
cardDisabled: '#40304742',
|
||||
textPrimary: '#FFF1FFF9',
|
||||
textMuted: '#8897E3D0',
|
||||
accent: '#FF43E0B2',
|
||||
accentSoft: '#8043E0B2',
|
||||
strokeSoft: '#6071B5A6',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF0B2A24', end: '#FF1B3E36', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF0F3A32', end: '#FF1A6E59', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF132521', end: '#FF0F1E1B', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF3DE3B4', end: '#FFB7F2D9', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF48E6B9', end: '#FF7EF0D6', angle: 120 },
|
||||
},
|
||||
{
|
||||
id: 'peach',
|
||||
name: 'Peach',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF1A1210',
|
||||
windowHeader: '#FF221510',
|
||||
windowStroke: '#605C3A2A',
|
||||
surface: '#FF241814',
|
||||
surfaceHover: '#FF2F211A',
|
||||
cardEnabled: '#50F2A269',
|
||||
cardDisabled: '#40382A22',
|
||||
textPrimary: '#FFFFF7F0',
|
||||
textMuted: '#88E3C9A7',
|
||||
accent: '#FFFFA46B',
|
||||
accentSoft: '#80FFB680',
|
||||
strokeSoft: '#60F5D7B0',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2A1512', end: '#FF4B241A', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF3B1B14', end: '#FF6A301F', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF241814', end: '#FF1C1310', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFFFB47A', end: '#FFFF7FA8', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFFFC28E', end: '#FFFF8A5A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'lilac',
|
||||
name: 'Lilac',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF14131E',
|
||||
windowHeader: '#FF1A1728',
|
||||
windowStroke: '#60433A5A',
|
||||
surface: '#FF1B172A',
|
||||
surfaceHover: '#FF241D36',
|
||||
cardEnabled: '#506C5CBE',
|
||||
cardDisabled: '#40312644',
|
||||
textPrimary: '#FFF5F2FF',
|
||||
textMuted: '#88CFC6E6',
|
||||
accent: '#FFB79BFF',
|
||||
accentSoft: '#80C8B0FF',
|
||||
strokeSoft: '#60705A9C',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF241A3B', end: '#FF1A3A55', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF2F214A', end: '#FF3D2F6A', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1B172A', end: '#FF15121F', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFB79BFF', end: '#FF7AD9FF', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFC5B0FF', end: '#FF8BB2FF', angle: 120 },
|
||||
},
|
||||
{
|
||||
id: 'sand',
|
||||
name: 'Sand',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF181510',
|
||||
windowHeader: '#FF201A13',
|
||||
windowStroke: '#6050432F',
|
||||
surface: '#FF221B14',
|
||||
surfaceHover: '#FF2C231B',
|
||||
cardEnabled: '#50E8C98C',
|
||||
cardDisabled: '#40322A21',
|
||||
textPrimary: '#FFFFF4E8',
|
||||
textMuted: '#88D8C4A2',
|
||||
accent: '#FFE8C98C',
|
||||
accentSoft: '#80F0D8A8',
|
||||
strokeSoft: '#607A6A54',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2A2016', end: '#FF3A2F22', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF32261B', end: '#FF4A3A2B', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF221B14', end: '#FF1A1510', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFF2D29B', end: '#FFE8B67A', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFF5E0B2', end: '#FFD1A36C', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'dusk',
|
||||
name: 'Dusk',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF141018',
|
||||
windowHeader: '#FF1B1422',
|
||||
windowStroke: '#60503A4A',
|
||||
surface: '#FF1D1626',
|
||||
surfaceHover: '#FF271B34',
|
||||
cardEnabled: '#50FF875A',
|
||||
cardDisabled: '#4030263B',
|
||||
textPrimary: '#FFF6F1FF',
|
||||
textMuted: '#88D6C9E6',
|
||||
accent: '#FFFF8A4C',
|
||||
accentSoft: '#80FF9B5A',
|
||||
strokeSoft: '#606B5A7A',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2B1630', end: '#FF6B2A1F', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF3B1B3A', end: '#FF8A3B24', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1D1626', end: '#FF141018', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF7C4BFF', end: '#FFFF9B3D', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFA775FF', end: '#FFFF7A3A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'prism',
|
||||
name: 'Prism',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF0F1220',
|
||||
windowHeader: '#FF141A2B',
|
||||
windowStroke: '#60435173',
|
||||
surface: '#FF151C2E',
|
||||
surfaceHover: '#FF1B243A',
|
||||
cardEnabled: '#504B7CFF',
|
||||
cardDisabled: '#4031324A',
|
||||
textPrimary: '#FFF3F6FF',
|
||||
textMuted: '#88C2CDEB',
|
||||
accent: '#FF5AC8FA',
|
||||
accentSoft: '#8074B6FF',
|
||||
strokeSoft: '#606B7A9E',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF1A243A', end: '#FF2A1E4A', angle: 135 },
|
||||
headerGradient: { enabled: true, start: '#FF223050', end: '#FF352060', angle: 135 },
|
||||
surfaceGradient: { enabled: true, start: '#FF151C2E', end: '#FF101522', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF59C4FF', end: '#FFB35CFF', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF7AD4FF', end: '#FFE36BFF', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'cinder',
|
||||
name: 'Cinder',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF120E0F',
|
||||
windowHeader: '#FF181012',
|
||||
windowStroke: '#604A2F32',
|
||||
surface: '#FF1A1214',
|
||||
surfaceHover: '#FF23171A',
|
||||
cardEnabled: '#50D24A4A',
|
||||
cardDisabled: '#40281A1C',
|
||||
textPrimary: '#FFFFF3F3',
|
||||
textMuted: '#88E3B9B9',
|
||||
accent: '#FFDC4A4A',
|
||||
accentSoft: '#80E06A6A',
|
||||
strokeSoft: '#606B4E52',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF1A0C0C', end: '#FF2A1416', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF241012', end: '#FF3A191C', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1A1214', end: '#FF120E0F', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFDC4A4A', end: '#FF401010', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFE26A6A', end: '#FF7A2A2A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
name: 'Forest',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF101611',
|
||||
windowHeader: '#FF141E15',
|
||||
windowStroke: '#6043573D',
|
||||
surface: '#FF161F18',
|
||||
surfaceHover: '#FF1D2B20',
|
||||
cardEnabled: '#5071C36C',
|
||||
cardDisabled: '#40263225',
|
||||
textPrimary: '#FFF3FFF1',
|
||||
textMuted: '#88C5E3C1',
|
||||
accent: '#FF7DD36B',
|
||||
accentSoft: '#807DD36B',
|
||||
strokeSoft: '#60607B58',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF1A241C', end: '#FF2B3A24', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF1F2B21', end: '#FF2F4B2E', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF161F18', end: '#FF101611', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF7DD36B', end: '#FFC6E886', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF9BE07C', end: '#FF6BC26A', angle: 45 },
|
||||
},
|
||||
]
|
||||
54
frontend/src/features/themes/tests/activeTheme.test.tsx
Normal file
54
frontend/src/features/themes/tests/activeTheme.test.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { ActiveThemeProvider, useActiveTheme } from '../useActiveTheme'
|
||||
|
||||
function Probe() {
|
||||
const { theme, setTheme, userPicked } = useActiveTheme()
|
||||
return (
|
||||
<>
|
||||
<p data-testid="id">{theme.id}</p>
|
||||
<p data-testid="picked">{String(userPicked)}</p>
|
||||
<button onClick={() => setTheme('nitro', 'user')}>user</button>
|
||||
<button onClick={() => setTheme('sunset', 'demo')}>demo</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear()
|
||||
document.documentElement.removeAttribute('style')
|
||||
})
|
||||
|
||||
test('a user pick persists to lv_theme, re-accents the page and locks out demo switches', () => {
|
||||
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('classic')
|
||||
act(() => screen.getByText('demo').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('sunset')
|
||||
expect(window.localStorage.getItem('lv_theme')).toBeNull()
|
||||
act(() => screen.getByText('user').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
|
||||
expect(window.localStorage.getItem('lv_theme')).toBe('nitro')
|
||||
expect(document.documentElement.style.getPropertyValue('--color-ice')).toBe('rgb(122, 92, 255)')
|
||||
act(() => screen.getByText('demo').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
|
||||
})
|
||||
|
||||
test('restores the stored theme and survives blocked storage', () => {
|
||||
window.localStorage.setItem('lv_theme', 'mint')
|
||||
const { unmount } = render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('mint')
|
||||
expect(screen.getByTestId('picked')).toHaveTextContent('true')
|
||||
unmount()
|
||||
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('classic')
|
||||
act(() => screen.getByText('user').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
39
frontend/src/features/themes/tests/presets.test.ts
Normal file
39
frontend/src/features/themes/tests/presets.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../presets.generated'
|
||||
import { argbToCss, parseArgb } from '../types'
|
||||
|
||||
const javaSources = import.meta.glob<string>(
|
||||
'../../../../../mod/src/main/java/dev/loki/lovisual/features/theme/presets/ThemePresets*.java',
|
||||
{ query: '?raw', import: 'default', eager: true },
|
||||
)
|
||||
|
||||
test('generated presets include Classic with the mod accent', () => {
|
||||
const classic = PRESETS.find((p) => p.id === 'classic')
|
||||
expect(classic?.name).toBe('Classic')
|
||||
expect(classic?.theme.accent).toBe('#FF5CC8E7')
|
||||
expect(classic?.theme.windowBg).toBe('#F012191B')
|
||||
// 0x334046 in Java is a 6-digit literal: alpha 00.
|
||||
expect(classic?.theme.cardDisabled).toBe('#00334046')
|
||||
})
|
||||
|
||||
test('one preset per ThemeEntry constant in the mod sources', () => {
|
||||
const sources = Object.values(javaSources)
|
||||
expect(sources).toHaveLength(3)
|
||||
const count = sources.reduce((n, src) => n + (src.match(/static\s+final\s+ThemeEntry\s+\w+\s*=/g)?.length ?? 0), 0)
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(PRESETS).toHaveLength(count)
|
||||
expect(new Set(PRESETS.map((p) => p.id)).size).toBe(count)
|
||||
})
|
||||
|
||||
test('derived header gradient matches ThemesBlending.subtleHeaderGradient', () => {
|
||||
const classic = PRESETS.find((p) => p.id === 'classic')
|
||||
// 0xF0142226 lightened by 8% and darkened by 6%, as the mod computes it.
|
||||
expect(classic?.headerGradient).toEqual({ enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 })
|
||||
})
|
||||
|
||||
test('argbToCss converts packed ARGB', () => {
|
||||
expect(argbToCss('#F012191B')).toBe('rgba(18, 25, 27, 0.941)')
|
||||
expect(argbToCss('#FF5CC8E7')).toBe('rgba(92, 200, 231, 1)')
|
||||
expect(argbToCss('#00334046')).toBe('rgba(51, 64, 70, 0)')
|
||||
expect(parseArgb('#5CC8E7')).toEqual({ a: 255, r: 92, g: 200, b: 231 })
|
||||
})
|
||||
55
frontend/src/features/themes/types.ts
Normal file
55
frontend/src/features/themes/types.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/** The mod's 12 palette slots (`Themes.Theme` record), each as `#AARRGGBB`. */
|
||||
export type ThemeColors = {
|
||||
windowBg: string
|
||||
windowHeader: string
|
||||
windowStroke: string
|
||||
surface: string
|
||||
surfaceHover: string
|
||||
cardEnabled: string
|
||||
cardDisabled: string
|
||||
textPrimary: string
|
||||
textMuted: string
|
||||
accent: string
|
||||
accentSoft: string
|
||||
strokeSoft: string
|
||||
}
|
||||
|
||||
/** Mirrors `Themes.GradientSpec`: when disabled the slot renders flat with `start`. */
|
||||
export type GradientSpec = { enabled: boolean; start: string; end: string; angle: number }
|
||||
|
||||
/** Mirrors `Themes.ThemeEntry`. */
|
||||
export type ThemeEntry = {
|
||||
id: string
|
||||
name: string
|
||||
builtin: boolean
|
||||
theme: ThemeColors
|
||||
windowGradient: GradientSpec
|
||||
headerGradient: GradientSpec
|
||||
surfaceGradient: GradientSpec
|
||||
cardGradient: GradientSpec
|
||||
strokeGradient: GradientSpec
|
||||
}
|
||||
|
||||
type Rgba = { r: number; g: number; b: number; a: number }
|
||||
|
||||
/** Parses `#AARRGGBB` (the mod's packed ARGB) or `#RRGGBB` (opaque). */
|
||||
export function parseArgb(hex: string): Rgba {
|
||||
const clean = hex.replace(/^#/, '')
|
||||
const full = clean.length === 6 ? `FF${clean}` : clean.padStart(8, '0')
|
||||
const n = Number.parseInt(full, 16) >>> 0
|
||||
return { a: (n >>> 24) & 0xff, r: (n >>> 16) & 0xff, g: (n >>> 8) & 0xff, b: n & 0xff }
|
||||
}
|
||||
|
||||
const alpha = (a: number) => String(Number((a / 255).toFixed(3)))
|
||||
|
||||
/** `#F012191B` → `rgba(18, 25, 27, 0.941)`. */
|
||||
export function argbToCss(hex: string): string {
|
||||
const { r, g, b, a } = parseArgb(hex)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha(a)})`
|
||||
}
|
||||
|
||||
/** Same color with its alpha replaced (0–1) — for glows built from a palette slot. */
|
||||
export function withAlpha(hex: string, a: number): string {
|
||||
const { r, g, b } = parseArgb(hex)
|
||||
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`
|
||||
}
|
||||
72
frontend/src/features/themes/useActiveTheme.ts
Normal file
72
frontend/src/features/themes/useActiveTheme.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { createContext, createElement, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { PRESETS } from './presets.generated'
|
||||
import { parseArgb, type ThemeEntry, withAlpha } from './types'
|
||||
|
||||
const STORAGE_KEY = 'lv_theme'
|
||||
const FALLBACK = PRESETS[0]
|
||||
|
||||
type ActiveTheme = {
|
||||
theme: ThemeEntry
|
||||
/** True once the visitor picked a theme themselves — demos must stop switching it then. */
|
||||
userPicked: boolean
|
||||
/** `source: 'user'` persists the choice; `'demo'` is a transient showcase switch. */
|
||||
setTheme: (id: string, source?: 'user' | 'demo') => void
|
||||
}
|
||||
|
||||
const ActiveThemeContext = createContext<ActiveTheme | null>(null)
|
||||
|
||||
function readStored(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function store(id: string) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
// Storage can be blocked (private mode, quota); the theme still applies for this visit.
|
||||
}
|
||||
}
|
||||
|
||||
const findPreset = (id: string | null) => PRESETS.find((p) => p.id === id)
|
||||
|
||||
/** Opaque accent for page chrome (buttons, links, focus rings). */
|
||||
function pageAccent(entry: ThemeEntry): string {
|
||||
const { r, g, b } = parseArgb(entry.theme.accent)
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
export function ActiveThemeProvider({ children, applyToPage = true }: { children: ReactNode; applyToPage?: boolean }) {
|
||||
const [state, setState] = useState(() => {
|
||||
const stored = findPreset(readStored())
|
||||
return { theme: stored ?? FALLBACK, userPicked: Boolean(stored) }
|
||||
})
|
||||
|
||||
const setTheme = useCallback((id: string, source: 'user' | 'demo' = 'user') => {
|
||||
const next = findPreset(id)
|
||||
if (!next) return
|
||||
if (source === 'user') store(id)
|
||||
setState((prev) => {
|
||||
if (source === 'demo' && prev.userPicked) return prev
|
||||
return { theme: next, userPicked: prev.userPicked || source === 'user' }
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!applyToPage) return
|
||||
const root = document.documentElement.style
|
||||
root.setProperty('--color-ice', pageAccent(state.theme))
|
||||
root.setProperty('--page-accent-glow', withAlpha(state.theme.theme.accent, 0.35))
|
||||
}, [state.theme, applyToPage])
|
||||
|
||||
const value = useMemo(() => ({ ...state, setTheme }), [state, setTheme])
|
||||
return createElement(ActiveThemeContext.Provider, { value }, children)
|
||||
}
|
||||
|
||||
/** The active ClickGui theme. Outside a provider it falls back to Classic with a no-op setter. */
|
||||
export function useActiveTheme(): ActiveTheme {
|
||||
return useContext(ActiveThemeContext) ?? { theme: FALLBACK, userPicked: false, setTheme: () => {} }
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "@fontsource/comfortaa/400.css";
|
||||
@import "@fontsource/comfortaa/700.css";
|
||||
@import "@fontsource/iosevka/400.css";
|
||||
@import "@fontsource/iosevka/700.css";
|
||||
@import "@fontsource-variable/unbounded";
|
||||
@import "@fontsource-variable/onest";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
@theme {
|
||||
--color-abyss: #0d1416;
|
||||
|
|
@ -14,22 +12,38 @@
|
|||
--color-ice: #5cc8e7;
|
||||
--color-ember: #e8835a;
|
||||
|
||||
--font-display: "Comfortaa", ui-rounded, system-ui, sans-serif;
|
||||
--font-sans: "Inter Variable", system-ui, sans-serif;
|
||||
--font-code: "Iosevka", ui-monospace, monospace;
|
||||
--font-display: "Unbounded Variable", system-ui, sans-serif;
|
||||
--font-sans: "Onest Variable", system-ui, sans-serif;
|
||||
--font-code: "JetBrains Mono Variable", ui-monospace, monospace;
|
||||
|
||||
--text-hero: 3.8125rem;
|
||||
--text-hero--line-height: 1.05;
|
||||
--radius-panel: 10px;
|
||||
}
|
||||
|
||||
/* The page accent follows the active ClickGui theme (useActiveTheme sets it on :root);
|
||||
registering it as a color lets theme switches cross-fade instead of snapping. */
|
||||
@property --color-ice {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #5cc8e7;
|
||||
}
|
||||
@property --page-accent-glow {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: rgb(92 200 231 / 0.35);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
|
||||
}
|
||||
body {
|
||||
@apply bg-abyss text-snow font-sans antialiased;
|
||||
min-height: 100dvh;
|
||||
/* Full-bleed sections span 100vw; never let that turn into a horizontal scrollbar. */
|
||||
overflow-x: clip;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { initI18n } from './app/i18n'
|
||||
import App from './app/App.tsx'
|
||||
|
||||
initI18n()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
76
frontend/src/pages/download/DownloadPage.tsx
Normal file
76
frontend/src/pages/download/DownloadPage.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DownloadButton } from '../../features/download/DownloadButton'
|
||||
import { ReleaseNotes } from '../../features/download/ReleaseNotes'
|
||||
import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
|
||||
|
||||
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
|
||||
const REQUIREMENTS = [
|
||||
{ key: 'reqMc', value: '26.2' },
|
||||
{ key: 'reqLoader', value: '0.19.4+' },
|
||||
{ key: 'reqApi', value: '0.152.2+26.2' },
|
||||
] as const
|
||||
|
||||
export default function DownloadPage() {
|
||||
const { t } = useTranslation('download')
|
||||
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
|
||||
const latest = data?.[0]
|
||||
const steps = ['1', '2', '3'] as const
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16">
|
||||
<header className="flex flex-col gap-4">
|
||||
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('page.title')}</h1>
|
||||
<p className="text-frost">{t('page.lead')}</p>
|
||||
<DownloadButton
|
||||
size="lg"
|
||||
className="mt-2"
|
||||
version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
|
||||
bytes={latest ? releaseSize(latest) : undefined}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.reqTitle')}</h2>
|
||||
<ul className="grid gap-2 sm:grid-cols-3">
|
||||
{REQUIREMENTS.map((r) => (
|
||||
<li key={r.key} className="rounded-panel border border-white/10 bg-black/30 p-4">
|
||||
<span className="block text-xs text-frost">{t(`page.${r.key}`)}</span>
|
||||
<span className="font-code text-sm text-snow">{r.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-frost">
|
||||
<strong className="text-snow">{t('page.reqOptionalTitle')}</strong> {t('page.reqOptional')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.installTitle')}</h2>
|
||||
<ol className="flex flex-col gap-2">
|
||||
{steps.map((s) => (
|
||||
<li key={s} className="flex items-baseline gap-3">
|
||||
<span className="lv-step-num">{s}</span>
|
||||
<span className="text-snow/90">{t(`page.step${s}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
|
||||
<a
|
||||
href={`https://github.com/${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-ice underline-offset-4 hover:underline"
|
||||
>
|
||||
{t('notes.allReleases')}
|
||||
</a>
|
||||
</div>
|
||||
<ReleaseNotes />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/download/DownloadRoute.tsx
Normal file
12
frontend/src/pages/download/DownloadRoute.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
|
||||
// Loaded on demand so react-markdown stays out of the landing bundle.
|
||||
const DownloadPage = lazy(() => import('./DownloadPage'))
|
||||
|
||||
export default function DownloadRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
|
||||
<DownloadPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
23
frontend/src/pages/public/HomePage.tsx
Normal file
23
frontend/src/pages/public/HomePage.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<ThemeStrip />
|
||||
<HudPlayground />
|
||||
<CommandHero />
|
||||
<HowItWorks />
|
||||
<ModuleWall />
|
||||
<ShowcaseTeaser />
|
||||
<FaqDownload />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { LoginForm } from '../../features/auth/forms/LoginForm'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Вход</h1>
|
||||
<h1 className="text-3xl">{t('login.title')}</h1>
|
||||
<LoginForm />
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
17
frontend/src/pages/public/NotFoundPage.tsx
Normal file
17
frontend/src/pages/public/NotFoundPage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<section className="flex flex-col gap-4 py-16">
|
||||
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1>
|
||||
<p className="text-frost">
|
||||
{t('notFound.body')}{' '}
|
||||
<Link to="/" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('notFound.home')}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Новый аккаунт</h1>
|
||||
<p className="max-w-sm text-frost">
|
||||
Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.
|
||||
</p>
|
||||
<h1 className="text-3xl">{t('register.title')}</h1>
|
||||
<p className="max-w-sm text-frost">{t('register.subtitle')}</p>
|
||||
<RegisterForm />
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
30
frontend/src/pages/themes/ThemesPage.tsx
Normal file
30
frontend/src/pages/themes/ThemesPage.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
|
||||
import { fromShareHash } from '../../features/themes/editor/codec'
|
||||
import { PRESETS } from '../../features/themes/presets.generated'
|
||||
import '../../features/themes/editor/editor.css'
|
||||
|
||||
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
|
||||
export default function ThemesPage() {
|
||||
const { t } = useTranslation('themes')
|
||||
|
||||
const initial = useMemo(() => {
|
||||
const shared = /#t=([^&]+)/.exec(window.location.hash)?.[1]
|
||||
if (shared) {
|
||||
const loaded = fromShareHash(shared)
|
||||
if (!('error' in loaded)) return loaded
|
||||
}
|
||||
return PRESETS[0]
|
||||
}, [])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/themes/ThemesRoute.tsx
Normal file
12
frontend/src/pages/themes/ThemesRoute.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
|
||||
// Code-split so the editor and its CSS stay out of the landing bundle.
|
||||
const ThemesPage = lazy(() => import('./ThemesPage'))
|
||||
|
||||
export default function ThemesRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
|
||||
<ThemesPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export function createApiClient(
|
|||
headers.set('content-type', 'application/json')
|
||||
body = JSON.stringify(options.json)
|
||||
}
|
||||
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
if (accessToken && !path.startsWith('/auth/')) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
return fetchImpl(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import { ApiError } from './client'
|
||||
|
||||
const BY_STATUS: Record<number, string> = {
|
||||
400: 'Проверь введённые данные.',
|
||||
401: 'Нужно войти в аккаунт.',
|
||||
403: 'Недостаточно прав.',
|
||||
404: 'Не найдено.',
|
||||
409: 'Уже существует.',
|
||||
}
|
||||
const BY_STATUS = {
|
||||
400: 'errors.badRequest',
|
||||
401: 'errors.unauthorized',
|
||||
403: 'errors.forbidden',
|
||||
404: 'errors.notFound',
|
||||
409: 'errors.conflict',
|
||||
} as const
|
||||
|
||||
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
export function describeError(t: TFunction<'common'>, err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 429) {
|
||||
return err.retryAfter
|
||||
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
|
||||
: 'Слишком много попыток. Подожди немного.'
|
||||
? t('errors.rateLimited', { seconds: err.retryAfter })
|
||||
: t('errors.rateLimitedGeneric')
|
||||
}
|
||||
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
|
||||
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
|
||||
if (err.status >= 500) return t('errors.serverError')
|
||||
const key = BY_STATUS[err.status as keyof typeof BY_STATUS]
|
||||
return overrides[err.status] ?? (key ? t(key) : t('errors.badRequest'))
|
||||
}
|
||||
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
|
||||
return t('errors.network')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { ApiError, createApiClient } from '../client'
|
||||
import { describeError } from '../errors'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
const t = initI18n('ru').getFixedT('ru', 'common')
|
||||
|
||||
test('sends json with bearer token and cookies', async () => {
|
||||
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
|
||||
const api = createApiClient()
|
||||
|
|
@ -62,6 +65,15 @@ test('auth endpoints never trigger refresh', async () => {
|
|||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('never sends a stale bearer token on /auth/* requests', async () => {
|
||||
const fetch = mockFetch({ 'POST /auth/logout': () => new Response(null, { status: 204 }) })
|
||||
const api = createApiClient()
|
||||
api.setAccessToken('stale')
|
||||
await api.request('/auth/logout', { method: 'POST' })
|
||||
const [, init] = fetch.mock.calls[0]
|
||||
expect(new Headers(init?.headers).get('authorization')).toBeNull()
|
||||
})
|
||||
|
||||
test('429 carries retry-after; 204 resolves undefined', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
|
||||
|
|
@ -73,10 +85,10 @@ test('429 carries retry-after; 204 resolves undefined', async () => {
|
|||
})
|
||||
|
||||
test('describeError covers 429, network failure and status defaults', () => {
|
||||
expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
|
||||
expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
|
||||
expect(describeError(new TypeError('Failed to fetch'))).toBe(
|
||||
expect(describeError(t, new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
|
||||
expect(describeError(t, new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
|
||||
expect(describeError(t, new TypeError('Failed to fetch'))).toBe(
|
||||
'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
|
||||
)
|
||||
expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
expect(describeError(t, new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
})
|
||||
|
|
|
|||
24
frontend/src/shared/i18n/LanguageSwitch.tsx
Normal file
24
frontend/src/shared/i18n/LanguageSwitch.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LANGS = ['ru', 'en'] as const
|
||||
|
||||
export function LanguageSwitch() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const current = i18n.language
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm text-frost" role="group" aria-label="Language">
|
||||
{LANGS.map((lng) => (
|
||||
<button
|
||||
key={lng}
|
||||
type="button"
|
||||
aria-pressed={current === lng}
|
||||
onClick={() => i18n.changeLanguage(lng)}
|
||||
className={`rounded-md px-2 py-1 transition-colors ${current === lng ? 'text-snow' : 'hover:text-snow'}`}
|
||||
>
|
||||
{t(`languageSwitch.${lng}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
frontend/src/shared/i18n/common.en.ts
Normal file
52
frontend/src/shared/i18n/common.en.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { commonRu, Translation } from './common.ru'
|
||||
|
||||
export const commonEn = {
|
||||
skipToContent: 'Skip to content',
|
||||
nav: {
|
||||
mainNav: 'Main navigation',
|
||||
showcase: 'Showcase',
|
||||
myConfigs: 'My configs',
|
||||
linkGame: 'Link game',
|
||||
menu: 'Menu',
|
||||
login: 'Log in',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
body: 'This link may be outdated.',
|
||||
home: 'Go home',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
},
|
||||
languageSwitch: {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
footer: {
|
||||
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
|
||||
sectionProduct: 'Product',
|
||||
sectionProject: 'Project',
|
||||
github: 'GitHub',
|
||||
download: 'Download',
|
||||
legalTitle: 'Legal',
|
||||
disclaimer: 'Not affiliated with Mojang or Microsoft.',
|
||||
},
|
||||
time: {
|
||||
days_one: '{{count}} day',
|
||||
days_few: '{{count}} days',
|
||||
days_many: '{{count}} days',
|
||||
days_other: '{{count}} days',
|
||||
},
|
||||
errors: {
|
||||
badRequest: 'Check what you entered.',
|
||||
unauthorized: 'You need to log in.',
|
||||
forbidden: 'Not enough permissions.',
|
||||
notFound: 'Not found.',
|
||||
conflict: 'Already exists.',
|
||||
rateLimited: 'Too many attempts. Wait {{seconds}}s.',
|
||||
rateLimitedGeneric: 'Too many attempts. Wait a bit.',
|
||||
serverError: "The server didn't respond. Try again in a minute.",
|
||||
network: 'No connection to the server. Check your internet and try again.',
|
||||
},
|
||||
} satisfies Translation<typeof commonRu>
|
||||
54
frontend/src/shared/i18n/common.ru.ts
Normal file
54
frontend/src/shared/i18n/common.ru.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export const commonRu = {
|
||||
skipToContent: 'Перейти к содержимому',
|
||||
nav: {
|
||||
mainNav: 'Основная навигация',
|
||||
showcase: 'Витрина',
|
||||
myConfigs: 'Мои конфиги',
|
||||
linkGame: 'Привязать игру',
|
||||
menu: 'Меню',
|
||||
login: 'Войти',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Такой страницы нет',
|
||||
body: 'Возможно, ссылка устарела.',
|
||||
home: 'На главную',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Скопировать',
|
||||
copied: 'Скопировано',
|
||||
},
|
||||
languageSwitch: {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
footer: {
|
||||
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
|
||||
sectionProduct: 'Продукт',
|
||||
sectionProject: 'Проект',
|
||||
github: 'GitHub',
|
||||
download: 'Скачать',
|
||||
legalTitle: 'Правовое',
|
||||
disclaimer: 'Не связано с Mojang или Microsoft.',
|
||||
},
|
||||
time: {
|
||||
days_one: '{{count}} день',
|
||||
days_few: '{{count}} дня',
|
||||
days_many: '{{count}} дней',
|
||||
days_other: '{{count}} дня',
|
||||
},
|
||||
errors: {
|
||||
badRequest: 'Проверь введённые данные.',
|
||||
unauthorized: 'Нужно войти в аккаунт.',
|
||||
forbidden: 'Недостаточно прав.',
|
||||
notFound: 'Не найдено.',
|
||||
conflict: 'Уже существует.',
|
||||
rateLimited: 'Слишком много попыток. Подожди {{seconds}} с.',
|
||||
rateLimitedGeneric: 'Слишком много попыток. Подожди немного.',
|
||||
serverError: 'Сервер не ответил. Попробуй через минуту.',
|
||||
network: 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Translation<T> = {
|
||||
[K in keyof T]: T[K] extends string ? string : Translation<T[K]>
|
||||
}
|
||||
33
frontend/src/shared/i18n/tests/i18n.test.tsx
Normal file
33
frontend/src/shared/i18n/tests/i18n.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next'
|
||||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { LanguageSwitch } from '../LanguageSwitch'
|
||||
|
||||
function Probe() {
|
||||
const { t } = useTranslation('auth')
|
||||
return <p>{t('login.submit')}</p>
|
||||
}
|
||||
|
||||
test('switching language re-renders strings and sets <html lang>', async () => {
|
||||
const user = userEvent.setup()
|
||||
const i18n = initI18n('ru')
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Probe />
|
||||
<LanguageSwitch />
|
||||
</I18nextProvider>,
|
||||
)
|
||||
expect(screen.getByText('Войти')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'EN' }))
|
||||
expect(screen.getByText('Log in')).toBeInTheDocument()
|
||||
expect(document.documentElement.lang).toBe('en')
|
||||
})
|
||||
|
||||
test('russian plurals', () => {
|
||||
const i18n = initI18n('ru')
|
||||
expect(i18n.t('common:time.days', { count: 1 })).toBe('1 день')
|
||||
expect(i18n.t('common:time.days', { count: 3 })).toBe('3 дня')
|
||||
expect(i18n.t('common:time.days', { count: 5 })).toBe('5 дней')
|
||||
})
|
||||
23
frontend/src/shared/layout/AppShell.tsx
Normal file
23
frontend/src/shared/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Outlet } from 'react-router'
|
||||
import { Footer } from './Footer'
|
||||
import { TopBar } from './TopBar'
|
||||
|
||||
export function AppShell() {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:z-10 focus:rounded-md focus:bg-slate focus:px-3 focus:py-2"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
<TopBar />
|
||||
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 sm:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
49
frontend/src/shared/layout/Footer.tsx
Normal file
49
frontend/src/shared/layout/Footer.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { DOWNLOAD_URL, GITHUB_REPO } from '../../features/download/api'
|
||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||
|
||||
export function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
const product = [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/themes', label: 'Themes' },
|
||||
]
|
||||
const project = [
|
||||
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
|
||||
{ href: DOWNLOAD_URL, label: t('footer.download') },
|
||||
]
|
||||
|
||||
return (
|
||||
<footer className="mt-8 border-t border-shoal bg-slate">
|
||||
<div className="mx-auto grid w-full max-w-[1120px] gap-10 px-4 py-12 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Link to="/" className="font-display text-xl font-bold text-snow">LoVisual</Link>
|
||||
<p className="max-w-[38ch] text-sm text-frost">{t('footer.about')}</p>
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
|
||||
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProduct')}</span>
|
||||
{product.map((i) => (
|
||||
<Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProject')}</span>
|
||||
{project.map((i) => (
|
||||
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
|
||||
{i.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-shoal">
|
||||
<p className="mx-auto w-full max-w-[1120px] px-4 py-5 text-xs text-frost sm:px-6">{t('footer.disclaimer')}</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
94
frontend/src/shared/layout/TopBar.tsx
Normal file
94
frontend/src/shared/layout/TopBar.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, NavLink } from 'react-router'
|
||||
import { useSession } from '../../features/auth/session'
|
||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||
import type { Me } from '../types'
|
||||
import { ButtonLink } from '../ui/Button'
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation(['common', 'download'])
|
||||
const [open, setOpen] = useState(false)
|
||||
const nav = [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/link', label: t('nav.linkGame') },
|
||||
]
|
||||
|
||||
return (
|
||||
<header className="border-b border-shoal bg-slate">
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-wrap items-center gap-x-6 px-4 sm:px-6">
|
||||
<Link to="/" className="py-4 font-display text-xl font-bold text-snow">
|
||||
LoVisual
|
||||
</Link>
|
||||
<nav
|
||||
id="site-nav"
|
||||
aria-label={t('nav.mainNav')}
|
||||
className={`${open ? 'flex' : 'hidden'} order-last w-full flex-col gap-1 pb-3 sm:order-none sm:flex sm:w-auto sm:flex-row sm:gap-6 sm:pb-0`}
|
||||
>
|
||||
{nav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`py-2 text-sm transition-colors ${isActive ? 'text-snow' : 'text-frost hover:text-snow'}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<ButtonLink to="/download" variant="primary">
|
||||
{t('download:nav')}
|
||||
</ButtonLink>
|
||||
<LanguageSwitch />
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="site-nav"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-md px-2 py-2 text-sm text-frost hover:text-snow sm:hidden"
|
||||
>
|
||||
{t('nav.menu')}
|
||||
</button>
|
||||
<AccountButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountButton() {
|
||||
const { t } = useTranslation('common')
|
||||
const { status, me } = useSession()
|
||||
if (status === 'loading') return null
|
||||
if (!me) {
|
||||
return (
|
||||
<ButtonLink to="/login" variant="quiet">
|
||||
{t('nav.login')}
|
||||
</ButtonLink>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link to="/account" className="flex min-w-0 items-center gap-2 py-2 text-sm text-snow hover:text-ice">
|
||||
<Avatar me={me} />
|
||||
<span className="max-w-[8rem] truncate sm:max-w-[12rem]">{me.display_nick}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({ me }: { me: Me }) {
|
||||
if (me.avatar_url) {
|
||||
return <img src={me.avatar_url} alt="" className="size-7 shrink-0 rounded-full object-cover" />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-shoal font-display text-xs font-bold text-snow"
|
||||
>
|
||||
{me.display_nick.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
47
frontend/src/shared/layout/tests/layout.test.tsx
Normal file
47
frontend/src/shared/layout/tests/layout.test.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { routes } from '../../../app/routes'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('anonymous visitor sees nav and a login link', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login')
|
||||
expect(screen.getAllByRole('link', { name: 'Витрина' }).length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, которые видно')
|
||||
})
|
||||
|
||||
test('signed-in visitor sees their nick instead of login', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({ access_token: 't' }),
|
||||
'GET /me': () => json({ id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '' }),
|
||||
})
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByRole('link', { name: /Rider/ })).toHaveAttribute('href', '/account')
|
||||
expect(screen.queryByRole('link', { name: 'Войти' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('unknown path shows not found', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/definitely-not-here')
|
||||
expect(await screen.findByRole('heading', { name: 'Такой страницы нет' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('menu disclosure toggles aria-expanded', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
const toggle = await screen.findByRole('button', { name: 'Меню' })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await userEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('footer carries the github link and the disclaimer', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
|
||||
const gh = screen.getByRole('link', { name: 'GitHub' })
|
||||
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
|
||||
})
|
||||
7
frontend/src/shared/motion/reducedMotion.ts
Normal file
7
frontend/src/shared/motion/reducedMotion.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* True when the visitor asked for reduced motion. Unknown support (no matchMedia, e.g. jsdom)
|
||||
* counts as reduced: the safe default, and it keeps tests deterministic.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window.matchMedia !== 'function' || window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import { Link, type LinkProps } from 'react-router'
|
||||
|
||||
type Variant = 'primary' | 'quiet' | 'danger'
|
||||
|
||||
const BASE =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors'
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
primary: 'bg-ice text-abyss hover:bg-ice/90',
|
||||
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
|
||||
|
|
@ -21,8 +25,13 @@ export function Button({
|
|||
type={type}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
className={`${BASE} disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** A router link that looks like a button — for navigation CTAs. */
|
||||
export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) {
|
||||
return <Link className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from './Button'
|
||||
|
||||
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
||||
const { t } = useTranslation('common')
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
|
|
@ -19,10 +21,10 @@ export function ShareCode({ code, label }: { code: string; label?: string }) {
|
|||
{label ? <span className="text-sm text-frost">{label}</span> : null}
|
||||
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
|
||||
<Button variant="quiet" onClick={copy}>
|
||||
Скопировать
|
||||
{t('shareCode.copy')}
|
||||
</Button>
|
||||
<span aria-live="polite" className="text-sm text-frost">
|
||||
{copied ? 'Скопировано' : ''}
|
||||
{copied ? t('shareCode.copied') : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
import { initI18n } from '../app/i18n'
|
||||
|
||||
initI18n('ru')
|
||||
|
||||
afterEach(() => cleanup())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
// jsdom has no canvas backend; effects treat a null context as "nothing to draw".
|
||||
HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('router renders a route', () => {
|
||||
const router = createMemoryRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
render(<RouterProvider router={router} />)
|
||||
expect(screen.getByRole('heading', { name: 'LoVisual' })).toBeInTheDocument()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue