chore(history): squash 16 commit(s) from 2026-09-26
- chore(backend): sync Cargo.lock with gateway dev-dependency - feat(frontend): ClickGui replica matching the in-game GUI — category panels, Modules/Settings switch, theme cards - feat(frontend): floating glass top bar in the mod's UI language - fix(frontend): soft night backdrop for the ClickGui, editor that fits one screen - chore(frontend): replace em dashes in UI copy with lighter separators - refactor(frontend): split landing sections and theme editor into subfolders (4-files-per-folder rule) - feat(frontend): login and registration in the mod's glass panel style - feat(frontend): link the game with a device code - feat(frontend): account page with avatar upload and linked games - feat(frontend): my configs with share codes and publishing - feat(frontend): public showcase listing and detail pages - chore(frontend): drop em dashes from visible copy - feat(frontend): atmospheric backdrop, glass download block and ClickGui-style FAQ on the landing - feat(frontend): Konami code, DevTools console greeting and ASCII 404 block - feat: public player profiles at /u/:id and author link in the showcase - fix(frontend): stop the theme editor overflowing horizontally at 375px
This commit is contained in:
parent
7f4b532f99
commit
ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions
1
backend/Cargo.lock
generated
1
backend/Cargo.lock
generated
|
|
@ -1589,6 +1589,7 @@ dependencies = [
|
|||
"common",
|
||||
"dotenvy",
|
||||
"governor",
|
||||
"http-body-util",
|
||||
"jsonwebtoken",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub struct Listing {
|
|||
pub copies_count: i32,
|
||||
pub published_at: DateTime<Utc>,
|
||||
pub author: Option<Author>,
|
||||
pub author_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
fn to_listing(row: ListingRow, author: Option<Author>) -> Listing {
|
||||
|
|
@ -34,6 +35,7 @@ fn to_listing(row: ListingRow, author: Option<Author>) -> Listing {
|
|||
config_name: row.name,
|
||||
copies_count: row.copies_count,
|
||||
published_at: row.published_at,
|
||||
author_id: author.as_ref().map(|_| row.account_id),
|
||||
author,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
name="description"
|
||||
content="Облачные конфиги, коды для друзей и витрина настроек для мода LoVisual."
|
||||
/>
|
||||
<title>LoVisual — аккаунт и облачные конфиги</title>
|
||||
<title>LoVisual · аккаунт и облачные конфиги</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
import i18next, { type i18n } from 'i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { accountEn } from '../features/account/i18n/en'
|
||||
import { accountRu } from '../features/account/i18n/ru'
|
||||
import { authEn } from '../features/auth/i18n/en'
|
||||
import { authRu } from '../features/auth/i18n/ru'
|
||||
import { clickguiEn } from '../features/clickgui/i18n/en'
|
||||
import { configsEn } from '../features/configs/i18n/en'
|
||||
import { clickguiRu } from '../features/clickgui/i18n/ru'
|
||||
import { configsRu } from '../features/configs/i18n/ru'
|
||||
import { deviceLinkEn } from '../features/device-link/i18n/en'
|
||||
import { deviceLinkRu } from '../features/device-link/i18n/ru'
|
||||
import { downloadEn } from '../features/download/i18n/en'
|
||||
import { profileEn } from '../features/profile/i18n/en'
|
||||
import { profileRu } from '../features/profile/i18n/ru'
|
||||
import { showcaseEn } from '../features/showcase/i18n/en'
|
||||
import { downloadRu } from '../features/download/i18n/ru'
|
||||
import { showcaseRu } from '../features/showcase/i18n/ru'
|
||||
import { landingEn } from '../features/landing/i18n/en'
|
||||
import { landingRu } from '../features/landing/i18n/ru'
|
||||
import { themesEn } from '../features/themes/i18n/en'
|
||||
|
|
@ -23,12 +33,36 @@ export function initI18n(lng?: 'ru' | 'en'): i18n {
|
|||
.init({
|
||||
lng,
|
||||
resources: {
|
||||
ru: { common: commonRu, auth: authRu, landing: landingRu, themes: themesRu, clickgui: clickguiRu, download: downloadRu },
|
||||
en: { common: commonEn, auth: authEn, landing: landingEn, themes: themesEn, clickgui: clickguiEn, download: downloadEn },
|
||||
ru: {
|
||||
common: commonRu,
|
||||
auth: authRu,
|
||||
landing: landingRu,
|
||||
themes: themesRu,
|
||||
clickgui: clickguiRu,
|
||||
download: downloadRu,
|
||||
deviceLink: deviceLinkRu,
|
||||
account: accountRu,
|
||||
configs: configsRu,
|
||||
showcase: showcaseRu,
|
||||
profile: profileRu,
|
||||
},
|
||||
en: {
|
||||
common: commonEn,
|
||||
auth: authEn,
|
||||
landing: landingEn,
|
||||
themes: themesEn,
|
||||
clickgui: clickguiEn,
|
||||
download: downloadEn,
|
||||
deviceLink: deviceLinkEn,
|
||||
account: accountEn,
|
||||
configs: configsEn,
|
||||
showcase: showcaseEn,
|
||||
profile: profileEn,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'ru',
|
||||
supportedLngs: ['ru', 'en'],
|
||||
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'],
|
||||
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'],
|
||||
defaultNS: 'common',
|
||||
interpolation: { escapeValue: false },
|
||||
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },
|
||||
|
|
|
|||
10
frontend/src/app/i18next.d.ts
vendored
10
frontend/src/app/i18next.d.ts
vendored
|
|
@ -1,7 +1,12 @@
|
|||
import type { accountRu } from '../features/account/i18n/ru'
|
||||
import type { authRu } from '../features/auth/i18n/ru'
|
||||
import type { clickguiRu } from '../features/clickgui/i18n/ru'
|
||||
import type { configsRu } from '../features/configs/i18n/ru'
|
||||
import type { deviceLinkRu } from '../features/device-link/i18n/ru'
|
||||
import type { downloadRu } from '../features/download/i18n/ru'
|
||||
import type { landingRu } from '../features/landing/i18n/ru'
|
||||
import type { profileRu } from '../features/profile/i18n/ru'
|
||||
import type { showcaseRu } from '../features/showcase/i18n/ru'
|
||||
import type { themesRu } from '../features/themes/i18n/ru'
|
||||
import type { commonRu } from '../shared/i18n/common.ru'
|
||||
|
||||
|
|
@ -15,6 +20,11 @@ declare module 'i18next' {
|
|||
themes: typeof themesRu
|
||||
clickgui: typeof clickguiRu
|
||||
download: typeof downloadRu
|
||||
deviceLink: typeof deviceLinkRu
|
||||
account: typeof accountRu
|
||||
configs: typeof configsRu
|
||||
showcase: typeof showcaseRu
|
||||
profile: typeof profileRu
|
||||
// Later tasks extend this with their own feature namespaces.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ import { AppShell } from '../shared/layout/AppShell'
|
|||
import HomePage from '../pages/public/HomePage'
|
||||
import LoginPage from '../pages/public/LoginPage'
|
||||
import RegisterPage from '../pages/public/RegisterPage'
|
||||
import ProfilePage from '../pages/public/ProfilePage'
|
||||
import NotFoundPage from '../pages/public/NotFoundPage'
|
||||
// Code-split: the download page pulls in react-markdown, so it loads on demand.
|
||||
import DownloadRoute from '../pages/download/DownloadRoute'
|
||||
import AccountPage from '../pages/app/AccountPage'
|
||||
import LinkPage from '../pages/app/LinkPage'
|
||||
import ThemesRoute from '../pages/themes/ThemesRoute'
|
||||
import ConfigsPage from '../pages/app/ConfigsPage'
|
||||
import ShowcaseRoute from '../pages/showcase/ShowcaseRoute'
|
||||
import ListingRoute from '../pages/showcase/ListingRoute'
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
|
|
@ -18,7 +24,17 @@ export const routes: RouteObject[] = [
|
|||
{ path: '/register', element: <RegisterPage /> },
|
||||
{ path: '/download', element: <DownloadRoute /> },
|
||||
{ path: '/themes', element: <ThemesRoute /> },
|
||||
{ element: <RequireAuth />, children: [] }, // Tasks 6–8 add /link, /account, /configs
|
||||
{ path: '/u/:id', element: <ProfilePage /> },
|
||||
{ path: '/showcase', element: <ShowcaseRoute /> },
|
||||
{ path: '/showcase/:id', element: <ListingRoute /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{ path: '/link', element: <LinkPage /> },
|
||||
{ path: '/account', element: <AccountPage /> },
|
||||
{ path: '/configs', element: <ConfigsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
27
frontend/src/features/account/api.ts
Normal file
27
frontend/src/features/account/api.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
|
||||
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }
|
||||
|
||||
const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp'])
|
||||
const MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** Client-side avatar checks so a bad file never reaches the server. */
|
||||
export function checkAvatarFile(file: File): string | undefined {
|
||||
if (!ALLOWED_TYPES.has(file.type)) return 'Подойдёт PNG, JPG или WebP.'
|
||||
if (file.size > MAX_BYTES) return 'Файл больше 5 МБ.'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function uploadAvatar(file: File): Promise<{ avatar_url: string }> {
|
||||
const body = new FormData()
|
||||
body.set('file', file)
|
||||
return api.request('/avatars', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export async function listDevices(): Promise<DeviceLink[]> {
|
||||
return api.request('/device/links')
|
||||
}
|
||||
|
||||
export async function revokeDevice(id: string): Promise<void> {
|
||||
await api.request(`/device/links/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
49
frontend/src/features/account/components/AvatarUpload.tsx
Normal file
49
frontend/src/features/account/components/AvatarUpload.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useId, useState, type ChangeEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { checkAvatarFile, uploadAvatar } from '../api'
|
||||
|
||||
/** A label-based file picker (keyboard reachable) that uploads and refreshes the cached profile. */
|
||||
export function AvatarUpload() {
|
||||
const { t } = useTranslation('account')
|
||||
const id = useId()
|
||||
const client = useQueryClient()
|
||||
const [error, setError] = useState<string>()
|
||||
const mutation = useMutation({
|
||||
mutationFn: uploadAvatar,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['me'] }),
|
||||
})
|
||||
|
||||
function onChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
const problem = checkAvatarFile(file)
|
||||
if (problem) {
|
||||
setError(problem)
|
||||
return
|
||||
}
|
||||
setError(undefined)
|
||||
mutation.mutate(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer rounded-md border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
|
||||
>
|
||||
{t('avatar.change')}
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/features/account/components/DeviceList.tsx
Normal file
72
frontend/src/features/account/components/DeviceList.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { listDevices, revokeDevice, type DeviceLink } from '../api'
|
||||
|
||||
function relativeDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
|
||||
}
|
||||
|
||||
function DeviceRow({ device }: { device: DeviceLink }) {
|
||||
const { t } = useTranslation('account')
|
||||
const client = useQueryClient()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => revokeDevice(device.id),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['devices'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-4 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm text-snow">{t('devices.linkedAt', { date: relativeDate(device.linked_at) })}</p>
|
||||
<p className="text-sm text-frost">
|
||||
{t('devices.lastSeen', { when: device.last_seen ? relativeDate(device.last_seen) : t('devices.neverSeen') })}
|
||||
</p>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="danger" busy={mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{t('devices.confirmRevoke')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={() => setConfirming(false)}>
|
||||
{t('devices.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setConfirming(true)}>
|
||||
{t('devices.revoke')}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** ClickGui-like rows listing devices the mod linked, each with a two-step confirm to revoke. */
|
||||
export function DeviceList() {
|
||||
const { t } = useTranslation('account')
|
||||
const query = useQuery({ queryKey: ['devices'], queryFn: listDevices })
|
||||
const devices = query.data ?? []
|
||||
|
||||
return (
|
||||
<div className="rounded-(--radius-panel) border border-shoal bg-slate/40 p-5">
|
||||
<h2 className="font-display text-lg">{t('devices.heading')}</h2>
|
||||
{devices.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-frost">
|
||||
{t('devices.empty')}{' '}
|
||||
<Link to="/link" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('devices.emptyLink')}
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{devices.map((device) => (
|
||||
<DeviceRow key={device.id} device={device} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
frontend/src/features/account/components/ProfileCard.tsx
Normal file
44
frontend/src/features/account/components/ProfileCard.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { useSession } from '../../auth/session'
|
||||
import { AvatarUpload } from './AvatarUpload'
|
||||
|
||||
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. */
|
||||
export function ProfileCard() {
|
||||
const { t } = useTranslation('account')
|
||||
const { me, signOut } = useSession()
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const joined = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(me.created_at))
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6 text-center">
|
||||
<div className="relative">
|
||||
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
|
||||
{me.avatar_url ? (
|
||||
<img src={me.avatar_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span aria-hidden="true">{me.display_nick.slice(0, 1).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AvatarUpload />
|
||||
<div>
|
||||
<p className="font-display text-xl">{me.display_nick}</p>
|
||||
<p className="text-sm text-frost">{me.email}</p>
|
||||
<p className="mt-1 text-sm text-frost">{t('profile.memberSince', { date: joined })}</p>
|
||||
</div>
|
||||
<Button variant="quiet" onClick={handleSignOut}>
|
||||
{t('profile.signOut')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontend/src/features/account/i18n/en.ts
Normal file
23
frontend/src/features/account/i18n/en.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { accountRu } from './ru'
|
||||
|
||||
export const accountEn = {
|
||||
profile: {
|
||||
memberSince: 'With us since {{date}}',
|
||||
signOut: 'Sign out',
|
||||
},
|
||||
avatar: {
|
||||
change: 'Change avatar',
|
||||
},
|
||||
devices: {
|
||||
heading: 'Linked games',
|
||||
linkedAt: 'Linked {{date}}',
|
||||
lastSeen: 'Last seen: {{when}}',
|
||||
neverSeen: 'never signed in yet',
|
||||
revoke: 'Unlink',
|
||||
confirmRevoke: 'Really unlink?',
|
||||
cancel: 'Cancel',
|
||||
empty: 'No linked games yet. Link the mod on the "Link the game" page.',
|
||||
emptyLink: 'Link the game',
|
||||
},
|
||||
} satisfies Translation<typeof accountRu>
|
||||
20
frontend/src/features/account/i18n/ru.ts
Normal file
20
frontend/src/features/account/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export const accountRu = {
|
||||
profile: {
|
||||
memberSince: 'С нами с {{date}}',
|
||||
signOut: 'Выйти',
|
||||
},
|
||||
avatar: {
|
||||
change: 'Сменить аватар',
|
||||
},
|
||||
devices: {
|
||||
heading: 'Привязанные игры',
|
||||
linkedAt: 'Привязано {{date}}',
|
||||
lastSeen: 'Последний вход: {{when}}',
|
||||
neverSeen: 'ещё не заходил',
|
||||
revoke: 'Отвязать',
|
||||
confirmRevoke: 'Точно отвязать?',
|
||||
cancel: 'Отмена',
|
||||
empty: 'Пока ни одной игры. Привяжи мод на странице «Привязать игру».',
|
||||
emptyLink: 'Привязать игру',
|
||||
},
|
||||
} as const
|
||||
33
frontend/src/features/account/tests/account.test.tsx
Normal file
33
frontend/src/features/account/tests/account.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { checkAvatarFile } from '../api'
|
||||
import { DeviceList } from '../components/DeviceList'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('avatar file checks', () => {
|
||||
expect(checkAvatarFile(new File(['x'], 'a.gif', { type: 'image/gif' }))).toBe('Подойдёт PNG, JPG или WebP.')
|
||||
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'a.png', { type: 'image/png' })
|
||||
expect(checkAvatarFile(big)).toBe('Файл больше 5 МБ.')
|
||||
expect(checkAvatarFile(new File(['x'], 'a.png', { type: 'image/png' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
test('revoking a device asks for confirmation first', async () => {
|
||||
const user = userEvent.setup()
|
||||
let devices = [{ id: 'd1', linked_at: '2026-09-20T10:00:00Z', last_seen: null }]
|
||||
const fetch = mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /device/links': () => json(devices),
|
||||
'DELETE /device/links/d1': () => {
|
||||
devices = []
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
renderApp([{ path: '/', element: <DeviceList /> }])
|
||||
const row = await screen.findByRole('listitem')
|
||||
await user.click(within(row).getByRole('button', { name: 'Отвязать' }))
|
||||
expect(fetch.mock.calls.some(([, i]) => i?.method === 'DELETE')).toBe(false)
|
||||
await user.click(within(row).getByRole('button', { name: 'Точно отвязать?' }))
|
||||
expect(await screen.findByText(/Пока ни одной игры/)).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import type { authRu } from './ru'
|
|||
|
||||
export const authEn = {
|
||||
checkingSession: 'Checking session…',
|
||||
splitPitch: 'Your LoVisual setup, on any computer: cloud configs, share codes for friends, and a showcase of ready-made builds.',
|
||||
login: {
|
||||
title: 'Log in',
|
||||
emailLabel: 'Email',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export const authRu = {
|
||||
checkingSession: 'Проверяем вход…',
|
||||
splitPitch: 'Твои настройки LoVisual на любом компьютере: облачные конфиги, коды для друзей и витрина готовых сборок.',
|
||||
login: {
|
||||
title: 'Вход',
|
||||
emailLabel: 'Почта',
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
|
||||
import { PRESETS } from '../themes/presets.generated'
|
||||
import type { ThemeEntry } from '../themes/types'
|
||||
import { useActiveTheme } from '../themes/useActiveTheme'
|
||||
import { DEMO, useDemo } from './demo'
|
||||
import { ModuleCard } from './ModuleCard'
|
||||
import './parts/clickgui.css'
|
||||
import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs'
|
||||
import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules'
|
||||
import { Backdrop } from './parts/Backdrop'
|
||||
import { DemoCursor, Icon } from './parts/Glyphs'
|
||||
import { QUICK_THEMES, TABS } from './parts/modules'
|
||||
import { ModulesScreen } from './screens/ModulesScreen'
|
||||
import './screens/screens.css'
|
||||
import { SettingsScreen } from './screens/SettingsScreen'
|
||||
import { themeToCssVars } from './themeVars'
|
||||
|
||||
type Props = {
|
||||
|
|
@ -17,16 +19,43 @@ type Props = {
|
|||
paused?: boolean
|
||||
/** Overrides the active theme — for previews. */
|
||||
theme?: ThemeEntry
|
||||
/**
|
||||
* Controlled theme picking: when set, theme cards in Settings call this instead of switching the
|
||||
* site-wide theme (the /themes editor loads the preset into its draft).
|
||||
*/
|
||||
onPickTheme?: (id: string) => void
|
||||
/** Image URL painted over the procedural backdrop (e.g. a real in-game F1 screenshot). */
|
||||
backdrop?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ClickGuiWindow({ demo = true, paused = false, theme: override, className = '' }: Props) {
|
||||
const HINTS = {
|
||||
modules: ['toggle', 'settings', 'moduleList', 'search', 'close', 'hide'],
|
||||
settings: ['pick', 'edit', 'wheel', 'close', 'hide'],
|
||||
} as const
|
||||
|
||||
function useClock(): string {
|
||||
const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
const [now, setNow] = useState(format)
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(format()), 15_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
return now
|
||||
}
|
||||
|
||||
/**
|
||||
* A faithful replica of the in-game ClickGui: the top pill bar (clock + Modules/Settings switch),
|
||||
* the four glass category panels, and the Settings → Темы page whose cards re-theme everything.
|
||||
*/
|
||||
export function ClickGuiWindow({ demo = true, paused = false, theme: override, onPickTheme, backdrop, className = '' }: Props) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
const { theme: active, setTheme } = useActiveTheme()
|
||||
const theme = override ?? active
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [reduced] = useState(prefersReducedMotion)
|
||||
const clock = useClock()
|
||||
const themeIdRef = useRef(theme.id)
|
||||
useEffect(() => {
|
||||
themeIdRef.current = theme.id
|
||||
|
|
@ -40,11 +69,14 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c
|
|||
const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number])
|
||||
return QUICK_THEMES[(i + 1) % QUICK_THEMES.length]
|
||||
},
|
||||
onTheme: (id) => setTheme(id, 'demo'),
|
||||
onTheme: (id) => (onPickTheme ? onPickTheme(id) : setTheme(id, 'demo')),
|
||||
})
|
||||
|
||||
const vars = useMemo(() => themeToCssVars(theme), [theme])
|
||||
const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0]
|
||||
const vars = useMemo(
|
||||
() => (backdrop ? { ...themeToCssVars(theme), '--gui-backdrop-image': `url(${JSON.stringify(backdrop)})` } : themeToCssVars(theme)),
|
||||
[theme, backdrop],
|
||||
)
|
||||
const pick = onPickTheme ?? ((id: string) => setTheme(id, 'user'))
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -54,75 +86,59 @@ export function ClickGuiWindow({ demo = true, paused = false, theme: override, c
|
|||
data-testid="clickgui-root"
|
||||
className={`lv-gui ${className}`}
|
||||
style={vars}
|
||||
onPointerEnter={(e) => e.pointerType === 'mouse' && setHovered(true)}
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="lv-gui-bloom" aria-hidden="true" />
|
||||
<div className="lv-gui-window">
|
||||
<header className="lv-gui-header">
|
||||
<span className="lv-gui-logo">
|
||||
<LogoMark />
|
||||
LoVisual
|
||||
</span>
|
||||
<nav className="lv-gui-tabs" aria-hidden="true">
|
||||
{TABS.map((tab, i) => (
|
||||
<span key={tab} className="lv-gui-tab" data-active={i === 0}>
|
||||
{tab}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
<span className="lv-gui-search" aria-hidden="true">
|
||||
<SearchGlyph />
|
||||
</span>
|
||||
</header>
|
||||
<div className="lv-gui-stage" data-tab={state.tab}>
|
||||
<div className="lv-gui-world" aria-hidden="true">
|
||||
<Backdrop />
|
||||
<div className="lv-gui-photo" />
|
||||
</div>
|
||||
<p className="lv-gui-watermark" aria-hidden="true">
|
||||
<b>LoVisual</b>·400 fps <span>0 ms</span>
|
||||
</p>
|
||||
|
||||
<div className="lv-gui-body">
|
||||
<aside className="lv-gui-cats" aria-label={t('categories')}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<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 (
|
||||
<div className="lv-gui-bar">
|
||||
<time className="lv-gui-clock" aria-label={t('clock')}>
|
||||
{clock}
|
||||
</time>
|
||||
<div className="lv-gui-tabs" role="tablist" aria-label={t('tabsLabel')}>
|
||||
<span className="lv-gui-tab-thumb" aria-hidden="true" />
|
||||
{TABS.map((id) => (
|
||||
<button
|
||||
key={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} />
|
||||
role="tab"
|
||||
className="lv-gui-tab"
|
||||
aria-selected={state.tab === id}
|
||||
data-demo={`tab:${id}`}
|
||||
onClick={() => dispatch({ type: 'tab', id })}
|
||||
>
|
||||
{t(`tabs.${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.tab === 'modules' && (
|
||||
<button type="button" className="lv-gui-search" aria-label={t('search')}>
|
||||
<Icon name="search" className="lv-gui-search-icon" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="lv-gui-screen" key={state.tab}>
|
||||
{state.tab === 'modules' ? (
|
||||
<ModulesScreen state={state} dispatch={dispatch} />
|
||||
) : (
|
||||
<SettingsScreen activeId={theme.id} onPick={pick} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="lv-gui-hints" aria-hidden="true">
|
||||
{HINTS[state.tab].map((k) => (
|
||||
<li key={k}>{t(`hints.${state.tab}.${k}` as `hints.modules.toggle`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DemoCursor cursor={cursor} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import type { Dispatch } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GuiAction, GuiState } from './demo'
|
||||
import { Chevron } from './parts/Glyphs'
|
||||
import type { GuiModule } from './parts/modules'
|
||||
import { SettingControls } from './parts/SettingControls'
|
||||
|
||||
type Props = { module: GuiModule; state: GuiState; dispatch: Dispatch<GuiAction> }
|
||||
|
||||
/** One module card: bullet title, description, divider, bind + settings chevron + status toggle. */
|
||||
export function ModuleCard({ module, state, dispatch }: Props) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
const { name, settings } = module
|
||||
const on = Boolean(state.enabled[name])
|
||||
const open = state.expanded === name && Boolean(settings)
|
||||
|
||||
return (
|
||||
<article className="lv-gui-card" data-on={on} data-open={open}>
|
||||
<span className="lv-gui-card-glow" aria-hidden="true" />
|
||||
<h3 className="lv-gui-card-title">
|
||||
<span aria-hidden="true">• </span>
|
||||
{name}
|
||||
</h3>
|
||||
<p className="lv-gui-card-desc">{t(`desc.${name}`)}</p>
|
||||
<div className="lv-gui-card-divider" aria-hidden="true" />
|
||||
<div className="lv-gui-card-foot">
|
||||
<span className="lv-gui-bind">
|
||||
{t('bind')}: <span>{t('bindNone')}</span>
|
||||
</span>
|
||||
{settings ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lv-gui-expand"
|
||||
aria-expanded={open}
|
||||
aria-label={t('settings', { name })}
|
||||
data-demo={`expand:${name}`}
|
||||
onClick={() => dispatch({ type: 'expand', module: name })}
|
||||
>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="lv-gui-bind lv-gui-nosettings">{t('noSettings')}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={t('toggle', { name })}
|
||||
data-demo={`toggle:${name}`}
|
||||
className="lv-gui-toggle"
|
||||
onClick={() => dispatch({ type: 'toggle', module: name })}
|
||||
>
|
||||
<span className="lv-gui-toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
{open && settings ? <SettingControls module={name} settings={settings} state={state} dispatch={dispatch} /> : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,73 +1,46 @@
|
|||
import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { CATEGORIES, type CategoryId, settingKey } from './parts/modules'
|
||||
import { CATEGORIES, type TabId } from './parts/modules'
|
||||
|
||||
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }
|
||||
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'theme'; target: string }
|
||||
|
||||
export const LOOP_MS = 12_500
|
||||
export const LOOP_MS = 11_000
|
||||
|
||||
/** The scripted loop: pick Visuals, switch Trails on, open it, drag Length, tick Rainbow, change theme, tidy up. */
|
||||
/** The scripted loop: toggle two modules, open Settings, pick a theme card, come back to Modules. */
|
||||
export const DEMO: DemoStep[] = [
|
||||
{ at: 400, action: 'move', target: 'cat:visuals' },
|
||||
{ at: 1100, action: 'click', target: 'cat:visuals' },
|
||||
{ at: 1700, action: 'move', target: 'toggle:Trails' },
|
||||
{ at: 2400, action: 'click', target: 'toggle:Trails' },
|
||||
{ at: 3000, action: 'move', target: 'expand:Trails' },
|
||||
{ at: 3600, action: 'click', target: 'expand:Trails' },
|
||||
{ at: 4300, action: 'move', target: 'knob:Trails.Length' },
|
||||
{ at: 4900, action: 'drag', target: 'slider:Trails.Length', value: 85 },
|
||||
{ at: 6300, action: 'move', target: 'check:Trails.Rainbow' },
|
||||
{ at: 6900, action: 'click', target: 'check:Trails.Rainbow' },
|
||||
{ at: 7700, action: 'move', target: 'theme:next' },
|
||||
{ at: 8400, action: 'theme', target: 'theme:next' },
|
||||
{ at: 9400, action: 'move', target: 'expand:Trails' },
|
||||
{ at: 10000, action: 'click', target: 'expand:Trails' },
|
||||
{ at: 10800, action: 'move', target: 'rest' },
|
||||
{ at: 400, action: 'move', target: 'module:AmbientParticles' },
|
||||
{ at: 1100, action: 'click', target: 'module:AmbientParticles' },
|
||||
{ at: 1800, action: 'move', target: 'module:DamageTint' },
|
||||
{ at: 2500, action: 'click', target: 'module:DamageTint' },
|
||||
{ at: 3300, action: 'move', target: 'tab:settings' },
|
||||
{ at: 4000, action: 'click', target: 'tab:settings' },
|
||||
{ at: 4800, action: 'move', target: 'theme:next' },
|
||||
{ at: 5600, action: 'theme', target: 'theme:next' },
|
||||
{ at: 7200, action: 'move', target: 'tab:modules' },
|
||||
{ at: 7900, action: 'click', target: 'tab:modules' },
|
||||
{ at: 8800, action: 'move', target: 'rest' },
|
||||
]
|
||||
|
||||
export type GuiState = {
|
||||
category: CategoryId
|
||||
enabled: Record<string, boolean>
|
||||
expanded: string | null
|
||||
values: Record<string, number | boolean | string>
|
||||
dragging: boolean
|
||||
}
|
||||
/** Modules the demo switches on — also enabled in the static reduced-motion frame. */
|
||||
const DEMO_TOGGLES = ['AmbientParticles', 'DamageTint']
|
||||
|
||||
export type GuiAction =
|
||||
| { type: 'category'; id: CategoryId }
|
||||
| { type: 'toggle'; module: string }
|
||||
| { type: 'expand'; module: string }
|
||||
| { type: 'set'; key: string; value: number | boolean | string }
|
||||
| { type: 'dragging'; on: boolean }
|
||||
| { type: 'reset' }
|
||||
export type GuiState = { tab: TabId; enabled: Record<string, boolean> }
|
||||
|
||||
export type GuiAction = { type: 'tab'; id: TabId } | { type: 'toggle'; module: string } | { type: 'reset' }
|
||||
|
||||
export function initialGuiState(finished = false): GuiState {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const values: GuiState['values'] = {}
|
||||
for (const c of CATEGORIES) {
|
||||
for (const m of c.modules) {
|
||||
enabled[m.name] = Boolean(m.on)
|
||||
for (const s of m.settings ?? []) values[settingKey(m.name, s.name)] = s.value
|
||||
}
|
||||
}
|
||||
if (!finished) return { category: 'combat', enabled, expanded: null, values, dragging: false }
|
||||
for (const c of CATEGORIES) for (const m of c.modules) enabled[m.name] = Boolean(m.on)
|
||||
// The resting "after the demo" frame, used under reduced motion.
|
||||
enabled.Trails = true
|
||||
values[settingKey('Trails', 'Length')] = 85
|
||||
return { category: 'visuals', enabled, expanded: 'Trails', values, dragging: false }
|
||||
if (finished) for (const name of DEMO_TOGGLES) enabled[name] = true
|
||||
return { tab: 'modules', enabled }
|
||||
}
|
||||
|
||||
export function guiReducer(state: GuiState, action: GuiAction): GuiState {
|
||||
switch (action.type) {
|
||||
case 'category':
|
||||
return { ...state, category: action.id, expanded: null }
|
||||
case 'tab':
|
||||
return { ...state, tab: action.id }
|
||||
case 'toggle':
|
||||
return { ...state, enabled: { ...state.enabled, [action.module]: !state.enabled[action.module] } }
|
||||
case 'expand':
|
||||
return { ...state, expanded: state.expanded === action.module ? null : action.module }
|
||||
case 'set':
|
||||
return { ...state, values: { ...state.values, [action.key]: action.value } }
|
||||
case 'dragging':
|
||||
return { ...state, dragging: action.on }
|
||||
case 'reset':
|
||||
return initialGuiState()
|
||||
}
|
||||
|
|
@ -84,17 +57,17 @@ type Options = {
|
|||
onTheme: (id: string) => void
|
||||
}
|
||||
|
||||
/** Layout position of `el` inside `root`, ignoring CSS transforms (the window is tilted in 3D). */
|
||||
function offsetWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
|
||||
let x = 0
|
||||
let y = 0
|
||||
let node: HTMLElement | null = el
|
||||
while (node && node !== root) {
|
||||
x += node.offsetLeft
|
||||
y += node.offsetTop
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
/** Visual position of `el` inside `root` in root-local pixels: rects see CSS transforms (the tab bar's
|
||||
* `translateX(-50%)`, the screen's entrance animation) that the `offsetLeft` chain silently skips.
|
||||
* `scale` undoes transforms applied by ancestors of the root (the hero's entrance scale). */
|
||||
function positionWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
|
||||
const rr = root.getBoundingClientRect()
|
||||
const tr = el.getBoundingClientRect()
|
||||
const scale = root.offsetWidth && rr.width ? rr.width / root.offsetWidth : 1
|
||||
return {
|
||||
x: (tr.left - rr.left) / scale + (tr.width / scale) * 0.62,
|
||||
y: (tr.top - rr.top) / scale + (tr.height / scale) * 0.5,
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } {
|
||||
|
|
@ -111,55 +84,52 @@ export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; sta
|
|||
const timers: number[] = []
|
||||
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
|
||||
let pendingTheme = ''
|
||||
/** When the pointer finishes its current travel; a press must not fire before that. */
|
||||
let travelEndsAt = 0
|
||||
|
||||
const locate = (target: string, fraction?: number) => {
|
||||
const locate = (target: string) => {
|
||||
const root = optsRef.current.rootRef.current
|
||||
if (!root) return null
|
||||
if (target === 'rest') return { x: root.offsetWidth * 0.86, y: root.offsetHeight * 0.9 }
|
||||
const resolved = target === 'theme:next' ? (pendingTheme ||= `theme:${optsRef.current.nextTheme()}`) : target
|
||||
const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`)
|
||||
if (!el) return null
|
||||
const { x, y } = offsetWithin(el, root)
|
||||
const fx = fraction ?? 0.5
|
||||
return { x: x + el.offsetWidth * fx, y: y + el.offsetHeight * 0.5 }
|
||||
return positionWithin(el, root)
|
||||
}
|
||||
|
||||
const move = (target: string, duration: number, fraction?: number) => {
|
||||
const p = locate(target, fraction)
|
||||
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration }))
|
||||
const move = (target: string, duration: number) => {
|
||||
const p = locate(target)
|
||||
if (!p) return
|
||||
travelEndsAt = performance.now() + duration
|
||||
setCursor((c) => ({ ...c, ...p, visible: true, duration }))
|
||||
}
|
||||
|
||||
const click = (fn: () => void) => {
|
||||
/** Press `target` only once the pointer is actually on it: timers trail the CSS travel, and a
|
||||
* target that moved or unmounted would mean a click on air. */
|
||||
const click = (target: string, fn: () => void) => {
|
||||
later(Math.max(0, travelEndsAt - performance.now()) + 60, () => {
|
||||
const p = locate(target)
|
||||
if (!p) return
|
||||
setCursor((c) => ({ ...c, ...p, visible: true, duration: 0 }))
|
||||
later(60, () => {
|
||||
setCursor((c) => ({ ...c, pressed: true }))
|
||||
later(160, () => {
|
||||
setCursor((c) => ({ ...c, pressed: false }))
|
||||
fn()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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:', '')))
|
||||
return click(step.target, () => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
|
||||
}
|
||||
if (step.action === 'drag') {
|
||||
const [module, setting] = arg.split('.')
|
||||
const value = Number(step.value ?? 0)
|
||||
setCursor((c) => ({ ...c, pressed: true }))
|
||||
dispatch({ type: 'dragging', on: true })
|
||||
move(step.target, 900, value / 100)
|
||||
dispatch({ type: 'set', key: settingKey(module, setting), value })
|
||||
return later(950, () => {
|
||||
setCursor((c) => ({ ...c, pressed: false }))
|
||||
dispatch({ type: 'dragging', on: false })
|
||||
})
|
||||
}
|
||||
click(() => {
|
||||
if (kind === 'cat') dispatch({ type: 'category', id: arg as CategoryId })
|
||||
if (kind === 'toggle') dispatch({ type: 'toggle', module: arg })
|
||||
if (kind === 'expand') dispatch({ type: 'expand', module: arg })
|
||||
if (kind === 'check') dispatch({ type: 'set', key: arg, value: true })
|
||||
click(step.target, () => {
|
||||
if (kind === 'tab') dispatch({ type: 'tab', id: arg as TabId })
|
||||
if (kind === 'module') dispatch({ type: 'toggle', module: arg })
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,47 +3,18 @@ import type { clickguiRu } from './ru'
|
|||
|
||||
export const clickguiEn = {
|
||||
windowLabel: 'Interactive replica of the LoVisual ClickGui',
|
||||
bind: 'Bind',
|
||||
bindNone: 'none',
|
||||
toggle: 'Toggle {{name}}',
|
||||
settings: '{{name}} settings',
|
||||
categories: 'Categories',
|
||||
tabsLabel: 'ClickGui sections',
|
||||
tabs: { modules: 'Modules', settings: 'Settings' },
|
||||
clock: 'Time',
|
||||
search: 'Search',
|
||||
categories: 'Module categories',
|
||||
module: '{{name}}: LMB toggles it',
|
||||
theme: '{{name}} theme',
|
||||
noSettings: 'No settings',
|
||||
desc: {
|
||||
PvpCooldowns: 'Cooldown timers for shield, pearls and totems',
|
||||
Predictions: 'Flight path of pearls and arrows',
|
||||
TargetESP: 'Animated marker on your current target',
|
||||
DamageTint: 'A colored flash when you take damage',
|
||||
NoFriendDamage: 'Stops you from hitting friends by accident',
|
||||
CombatLog: 'Hit and damage log in chat',
|
||||
TPSSync: 'Syncs timers to the server TPS',
|
||||
ProjectilePuncher: 'Knocks away incoming projectiles',
|
||||
Trails: 'A glowing trail behind the player',
|
||||
JumpCircles: 'Rings on the ground when you jump',
|
||||
HitBubbles: 'Damage bubbles on every hit',
|
||||
AmbientParticles: 'Soft particles floating around you',
|
||||
Wings: 'Animated wings on your back',
|
||||
ChinaHat: 'A cone hat with a gradient',
|
||||
ShaderSky: 'A shader sky with clouds',
|
||||
MotionBlur: 'Blur while you move',
|
||||
PostFX: 'Bloom, vignette and color grading',
|
||||
KillEffect: 'A lightning strike on kills',
|
||||
ThirdPerson: 'A smooth third-person camera',
|
||||
AutoTool: 'Picks the right tool for you',
|
||||
DeathCoords: 'Death coordinates in chat',
|
||||
AutoRespawn: 'Respawn without the death screen',
|
||||
ClickPearl: 'Throw a pearl with a mouse button',
|
||||
LockSlot: 'Protects slots from dropping',
|
||||
UseTracker: 'Counts item usage',
|
||||
NoDelay: 'Removes the jump delay',
|
||||
StreamerMode: 'Hides personal info on stream',
|
||||
NameProtect: 'Replaces your name on screen',
|
||||
HitSounds: 'Pick your own hit sound',
|
||||
ItemScroller: 'Move items around faster',
|
||||
MessageFilter: 'Filters chat spam',
|
||||
Optimize: 'Extra FPS without losing the looks',
|
||||
NoServerPack: 'Declines server resource packs',
|
||||
BetterMinecraft: 'Small interface improvements',
|
||||
themes: 'Themes',
|
||||
settingsNav: 'Settings sections',
|
||||
sidebar: { hud: 'HUD', ui: 'UI', themes: 'Themes', layout: 'Layout', account: 'Account', configs: 'Configs' },
|
||||
hints: {
|
||||
modules: { toggle: 'LMB - toggle module', settings: 'RMB - settings', moduleList: 'E - edit ModuleList', search: 'Ctrl+F - search', close: 'Esc - close ClickGUI', hide: 'Alt+H - hide hints' },
|
||||
settings: { pick: 'LMB - pick a theme', edit: 'Edit - open the theme editor', wheel: 'Wheel - scroll themes', close: 'Esc - close ClickGUI', hide: 'Alt+H - hide hints' },
|
||||
},
|
||||
} satisfies Translation<typeof clickguiRu>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,17 @@
|
|||
export const clickguiRu = {
|
||||
windowLabel: 'Интерактивная копия ClickGui LoVisual',
|
||||
bind: 'Бинд',
|
||||
bindNone: 'нет',
|
||||
toggle: 'Включить {{name}}',
|
||||
settings: 'Настройки {{name}}',
|
||||
categories: 'Категории',
|
||||
tabsLabel: 'Разделы ClickGui',
|
||||
tabs: { modules: 'Modules', settings: 'Settings' },
|
||||
clock: 'Время',
|
||||
search: 'Поиск',
|
||||
categories: 'Категории модулей',
|
||||
module: '{{name}}: ЛКМ: включить или выключить',
|
||||
theme: 'Тема {{name}}',
|
||||
noSettings: 'Без настроек',
|
||||
desc: {
|
||||
PvpCooldowns: 'Таймеры перезарядки щита, жемчуга и тотема',
|
||||
Predictions: 'Траектория полёта жемчуга и стрел',
|
||||
TargetESP: 'Анимированная метка на текущей цели',
|
||||
DamageTint: 'Цветная вспышка при получении урона',
|
||||
NoFriendDamage: 'Не даёт случайно ударить друга',
|
||||
CombatLog: 'Лог попаданий и урона в чате',
|
||||
TPSSync: 'Подстраивает таймеры под TPS сервера',
|
||||
ProjectilePuncher: 'Сбивает летящие в тебя снаряды',
|
||||
Trails: 'Светящийся шлейф за игроком',
|
||||
JumpCircles: 'Круги на земле при прыжке',
|
||||
HitBubbles: 'Пузыри с уроном при ударе',
|
||||
AmbientParticles: 'Мягкие частицы вокруг тебя',
|
||||
Wings: 'Анимированные крылья за спиной',
|
||||
ChinaHat: 'Конусная шляпа с градиентом',
|
||||
ShaderSky: 'Шейдерное небо с облаками',
|
||||
MotionBlur: 'Размытие в движении',
|
||||
PostFX: 'Bloom, виньетка и цветокоррекция',
|
||||
KillEffect: 'Эффект молнии при убийстве',
|
||||
ThirdPerson: 'Плавная камера от третьего лица',
|
||||
AutoTool: 'Сам берёт нужный инструмент',
|
||||
DeathCoords: 'Координаты смерти в чате',
|
||||
AutoRespawn: 'Возрождение без экрана смерти',
|
||||
ClickPearl: 'Жемчуг по кнопке мыши',
|
||||
LockSlot: 'Защищает слоты от выброса',
|
||||
UseTracker: 'Счётчик использования предметов',
|
||||
NoDelay: 'Убирает задержку прыжка',
|
||||
StreamerMode: 'Прячет личные данные на стриме',
|
||||
NameProtect: 'Заменяет твой ник на экране',
|
||||
HitSounds: 'Звук попадания на выбор',
|
||||
ItemScroller: 'Быстрое перекладывание предметов',
|
||||
MessageFilter: 'Фильтр спама в чате',
|
||||
Optimize: 'Лишние FPS без потери красоты',
|
||||
NoServerPack: 'Отказ от серверного ресурспака',
|
||||
BetterMinecraft: 'Мелкие улучшения интерфейса',
|
||||
themes: 'Темы',
|
||||
settingsNav: 'Разделы настроек',
|
||||
sidebar: { hud: 'HUD', ui: 'UI', themes: 'Темы', layout: 'Расположение', account: 'Аккаунт', configs: 'Конфиги' },
|
||||
hints: {
|
||||
modules: { toggle: 'ЛКМ - включить/выключить модуль', settings: 'ПКМ - настройки', moduleList: 'E - редактировать ModuleList', search: 'Ctrl+F - поиск', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
|
||||
settings: { pick: 'ЛКМ - выбрать тему', edit: 'Edit - открыть редактор темы', wheel: 'Колесо - листать темы', close: 'Esc - закрыть ClickGUI', hide: 'Alt+H - скрыть подсказки' },
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
128
frontend/src/features/clickgui/parts/Backdrop.tsx
Normal file
128
frontend/src/features/clickgui/parts/Backdrop.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { memo } from 'react'
|
||||
|
||||
/**
|
||||
* The world behind the ClickGui: a night sky over a frozen lake, drawn as one small SVG
|
||||
* (no images). Horizon at ~46%; the lake starts as dark as the sky and brightens towards the
|
||||
* viewer, with a mist band over the shoreline, so there is no hard edge behind the panels.
|
||||
* A real screenshot can replace it: set `--gui-backdrop-image` (or ClickGuiWindow's `backdrop`),
|
||||
* and `.lv-gui-photo` covers this scene.
|
||||
*/
|
||||
|
||||
/** Deterministic pseudo-random sequence, so the sky is identical on every render and in SSR. */
|
||||
function rng(seed: number) {
|
||||
let s = seed
|
||||
return () => {
|
||||
s = (s * 16807) % 2147483647
|
||||
return (s - 1) / 2147483646
|
||||
}
|
||||
}
|
||||
|
||||
const W = 560
|
||||
const HORIZON = 150
|
||||
|
||||
const rand = rng(7)
|
||||
const STARS = Array.from({ length: 80 }, () => ({
|
||||
x: rand() * W,
|
||||
y: rand() * (HORIZON - 30),
|
||||
r: rand() < 0.12 ? 0.9 : 0.45 + rand() * 0.35,
|
||||
o: 0.35 + rand() * 0.6,
|
||||
twinkle: rand() < 0.2,
|
||||
}))
|
||||
const FLAKES = Array.from({ length: 26 }, () => ({ x: rand() * W, y: rand() * 300, s: 0.8 + rand() * 1.1, d: 9 + rand() * 9 }))
|
||||
|
||||
/** A Minecraft spruce: a trunk and stacked needle tiers that alternate wide/narrow, snow on each tier. */
|
||||
function Spruce({ x, base, h }: { x: number; base: number; h: number }) {
|
||||
const u = h / 14
|
||||
const tiers = [5, 3.5, 4.5, 3, 3.5, 2, 2.5, 1, 1]
|
||||
return (
|
||||
<g>
|
||||
<rect x={x - u / 2} y={base - 2 * u} width={u} height={2 * u} fill="#1a1210" />
|
||||
{tiers.map((half, i) => {
|
||||
const y = base - (i + 2) * u - u * 0.35
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x - half * u} y={y} width={half * 2 * u} height={u * 1.05} fill={i % 2 ? '#0c1b1d' : '#0f2224'} />
|
||||
{i % 2 === 0 && <rect x={x - half * u + u * 0.6} y={y} width={half * 2 * u - u * 1.6} height={u * 0.4} fill="#d4dcf3" opacity={0.55} />}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
/** Stepped (voxel) ridge: one block column every `step` units. */
|
||||
function ridge(from: number, to: number, step: number, base: number, heights: number[]) {
|
||||
let d = `M${from} ${HORIZON + 6}`
|
||||
for (let x = from, i = 0; x < to; x += step, i++) d += ` V${base - heights[i % heights.length]} H${Math.min(x + step, to)}`
|
||||
return `${d} V${HORIZON + 6} Z`
|
||||
}
|
||||
|
||||
const TREES = [
|
||||
{ x: 14, base: 142, h: 62 }, { x: 44, base: 140, h: 48 }, { x: 70, base: 144, h: 36 }, { x: 100, base: 147, h: 22 },
|
||||
{ x: 268, base: 148, h: 14 }, { x: 281, base: 148, h: 10 },
|
||||
{ x: 440, base: 147, h: 22 }, { x: 488, base: 141, h: 44 }, { x: 516, base: 139, h: 58 }, { x: 548, base: 142, h: 50 },
|
||||
]
|
||||
|
||||
export const Backdrop = memo(function Backdrop() {
|
||||
return (
|
||||
<svg className="lv-gui-backdrop" viewBox={`0 0 ${W} 324`} preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lvgb-sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#020309" />
|
||||
<stop offset="0.55" stopColor="#060b1b" />
|
||||
<stop offset="1" stopColor="#141d3d" />
|
||||
</linearGradient>
|
||||
<linearGradient id="lvgb-lake" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#121a36" />
|
||||
<stop offset="0.3" stopColor="#262f5a" />
|
||||
<stop offset="0.7" stopColor="#434c84" />
|
||||
<stop offset="1" stopColor="#5d67a3" />
|
||||
</linearGradient>
|
||||
<linearGradient id="lvgb-mist" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#7d8ad0" stopOpacity="0" />
|
||||
<stop offset="0.5" stopColor="#7d8ad0" stopOpacity="0.22" />
|
||||
<stop offset="1" stopColor="#7d8ad0" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<radialGradient id="lvgb-moon" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stopColor="#dfe6ff" stopOpacity="0.28" />
|
||||
<stop offset="1" stopColor="#dfe6ff" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="lvgb-glint" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#c9d3ff" stopOpacity="0.16" />
|
||||
<stop offset="1" stopColor="#c9d3ff" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width={W} height={HORIZON + 6} fill="url(#lvgb-sky)" />
|
||||
{STARS.map((s, i) => (
|
||||
<circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#fff" opacity={s.o} className={s.twinkle ? 'lv-gui-twinkle' : undefined} style={{ animationDelay: `${(i % 7) * 0.6}s` }} />
|
||||
))}
|
||||
<circle cx={452} cy={44} r={46} fill="url(#lvgb-moon)" />
|
||||
<rect x={445} y={37} width={14} height={14} fill="#eef2ff" opacity={0.92} />
|
||||
<rect x={448} y={40} width={4} height={3} fill="#c9d0ea" opacity={0.7} />
|
||||
|
||||
<path d={ridge(0, W, 14, HORIZON, [18, 24, 30, 26, 20, 14, 10, 8, 12, 16, 22, 28, 34, 30, 24, 18, 12, 10, 14, 20, 26, 30, 24, 20, 16, 22, 28, 34, 38, 32, 26, 20, 16, 12, 10, 14, 18, 24, 28, 22])} fill="#1a2349" />
|
||||
<path d={ridge(0, W, 14, HORIZON, [18, 24, 30, 26, 20, 14, 10, 8, 12, 16, 22, 28, 34, 30, 24, 18, 12, 10, 14, 20, 26, 30, 24, 20, 16, 22, 28, 34, 38, 32, 26, 20, 16, 12, 10, 14, 18, 24, 28, 22])} fill="none" stroke="#9aa6d8" strokeWidth="0.6" opacity="0.35" />
|
||||
<path d={ridge(0, 120, 8, HORIZON, [10, 12, 12, 9, 7, 5, 5, 4, 3, 3, 2, 2, 1, 1, 0])} fill="#0d1530" />
|
||||
<path d={ridge(380, W, 8, HORIZON, [0, 1, 2, 3, 4, 6, 6, 8, 9, 10, 10, 12, 12, 11, 13, 12, 12, 11, 12, 13, 12, 12, 11])} fill="#0d1530" />
|
||||
{TREES.map((t, i) => (
|
||||
<Spruce key={i} {...t} />
|
||||
))}
|
||||
|
||||
<rect y={HORIZON} width={W} height={324 - HORIZON} fill="url(#lvgb-lake)" />
|
||||
<g stroke="#dfe6ff" strokeWidth="0.5">
|
||||
{[6, 14, 25, 40, 60, 86, 118, 156].map((dy, i) => (
|
||||
<line key={dy} x1="0" x2={W} y1={HORIZON + dy} y2={HORIZON + dy} opacity={0.02 + i * 0.008} />
|
||||
))}
|
||||
</g>
|
||||
<rect x={430} y={HORIZON} width={44} height={120} fill="url(#lvgb-glint)" />
|
||||
<rect y={HORIZON - 22} width={W} height={44} fill="url(#lvgb-mist)" />
|
||||
|
||||
<g fill="#fff">
|
||||
{FLAKES.map((f, i) => (
|
||||
<rect key={i} className="lv-gui-flake" x={f.x} y={f.y} width={f.s} height={f.s} opacity={0.55} style={{ animationDuration: `${f.d}s`, animationDelay: `${-i * 1.3}s` }} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,43 +1,88 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import type { Cursor } from '../demo'
|
||||
import type { CategoryId } from './modules'
|
||||
|
||||
const PATHS: Record<CategoryId, string> = {
|
||||
combat: 'M14.5 3.5 20.5 3.5 20.5 9.5 10 20 7 20 4 17 4 14ZM6 13.5 10.5 18M3.5 20.5 6.5 17.5',
|
||||
visuals: 'M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z',
|
||||
player: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c.8-3.8 3.9-6 7.5-6s6.7 2.2 7.5 6',
|
||||
misc: 'M5 5h5v5H5ZM14 5h5v5h-5ZM5 14h5v5H5ZM14 14h5v5h-5Z',
|
||||
}
|
||||
/** The Lucide icons the mod ships in `assets/lovisual/svg/`, drawn the same way (2px stroke). */
|
||||
const ICONS = {
|
||||
combat: (
|
||||
<>
|
||||
<polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5" />
|
||||
<path d="M13 19 19 13M16 16l4 4M19 21l2-2" />
|
||||
<polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5" />
|
||||
<path d="M5 14l4 4M7 17l-3 3M3 19l2 2" />
|
||||
</>
|
||||
),
|
||||
player: (
|
||||
<>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</>
|
||||
),
|
||||
visuals: (
|
||||
<>
|
||||
<path d="m17 14 3 3.3a1 1 0 0 1-.7 1.7H4.7a1 1 0 0 1-.7-1.7L7 14h-.3a1 1 0 0 1-.7-1.7L9 9h-.2A1 1 0 0 1 8 7.3L12 3l4 4.3a1 1 0 0 1-.8 1.7H15l3 3.3a1 1 0 0 1-.7 1.7H17Z" />
|
||||
<path d="M12 22v-3" />
|
||||
</>
|
||||
),
|
||||
other: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</>
|
||||
),
|
||||
check: <path d="M20 6 9 17l-5-5" />,
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-4.2-4.2" />
|
||||
</>
|
||||
),
|
||||
themes: (
|
||||
<>
|
||||
<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" />
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
layout: (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18M9 12h12M15 12v9" />
|
||||
</>
|
||||
),
|
||||
account: (
|
||||
<>
|
||||
<path d="M11.5 15H7a4 4 0 0 0-4 4v2" />
|
||||
<path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" />
|
||||
<circle cx="10" cy="7" r="4" />
|
||||
</>
|
||||
),
|
||||
configs: (
|
||||
<>
|
||||
<path d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.98a2 2 0 0 1 1.69.9l.66 1.2A2 2 0 0 0 12 6h8a2 2 0 0 1 2 2v3.3" />
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<path d="m14.3 19.5.9-.4M15.2 16.9l-.9-.4M16.9 15.2l-.4-.9M16.9 20.8l-.4.9M19.1 15.2l.4-.9M19.5 21.7l-.4-.9M20.8 16.9l.9-.4M20.8 19.1l.9.4" />
|
||||
</>
|
||||
),
|
||||
} satisfies Record<string, ReactNode>
|
||||
|
||||
export function CategoryIcon({ id }: { id: CategoryId }) {
|
||||
export type IconName = keyof typeof ICONS
|
||||
|
||||
export function Icon({ name, className = 'lv-gui-icon' }: { name: IconName; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="lv-gui-icon" aria-hidden="true">
|
||||
<path d={PATHS[id]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" className="lv-gui-chevron" data-open={open} aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogoMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="lv-gui-logo-mark" aria-hidden="true">
|
||||
<path d="M10 1.5 18.5 10 10 18.5 1.5 10Z" fill="var(--gui-accent)" />
|
||||
<path d="M10 5.5 14.5 10 10 14.5 5.5 10Z" fill="var(--gui-window-bg)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" className="lv-gui-icon" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ICONS[name]}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -57,13 +102,7 @@ export function DemoCursor({ cursor }: { cursor: Cursor }) {
|
|||
>
|
||||
<span className="lv-gui-cursor-ring" />
|
||||
<svg viewBox="0 0 20 24" width="20" height="24">
|
||||
<path
|
||||
d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z"
|
||||
fill="#fff"
|
||||
stroke="#0b1012"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z" fill="#fff" stroke="#0b1012" strokeWidth="1.3" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
import type { Dispatch } from 'react'
|
||||
import type { GuiAction, GuiState } from '../demo'
|
||||
import { type Setting, settingKey } from './modules'
|
||||
|
||||
const COLOR_CYCLE = ['var(--gui-accent)', '#ff6b9a', '#f2c14e', '#7cf29c', '#9d7bff']
|
||||
|
||||
type Props = { module: string; settings: Setting[]; state: GuiState; dispatch: Dispatch<GuiAction> }
|
||||
|
||||
/** Expanded settings of a module card: sliders, checkboxes and color dots like the in-game panel. */
|
||||
export function SettingControls({ module, settings, state, dispatch }: Props) {
|
||||
return (
|
||||
<div className="lv-gui-settings">
|
||||
{settings.map((s) => {
|
||||
const key = settingKey(module, s.name)
|
||||
const value = state.values[key] ?? s.value
|
||||
if (s.kind === 'slider') {
|
||||
return <Slider key={key} id={key} setting={s} value={Number(value)} dragging={state.dragging} dispatch={dispatch} />
|
||||
}
|
||||
if (s.kind === 'check') {
|
||||
const on = Boolean(value)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
data-demo={`check:${key}`}
|
||||
className="lv-gui-check-row"
|
||||
onClick={() => dispatch({ type: 'set', key, value: !on })}
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="lv-gui-check" data-on={on}>
|
||||
<svg viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path d="M2.5 6.2 5 8.6l4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const current = String(value)
|
||||
const next = COLOR_CYCLE[(COLOR_CYCLE.indexOf(current) + 1) % COLOR_CYCLE.length]
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="lv-gui-check-row"
|
||||
aria-label={s.name}
|
||||
onClick={() => dispatch({ type: 'set', key, value: next })}
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="lv-gui-color" style={{ background: current, boxShadow: `0 0 10px ${current}` }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SliderProps = {
|
||||
id: string
|
||||
setting: Extract<Setting, { kind: 'slider' }>
|
||||
value: number
|
||||
dragging: boolean
|
||||
dispatch: Dispatch<GuiAction>
|
||||
}
|
||||
|
||||
function Slider({ id, setting, value, dragging, dispatch }: SliderProps) {
|
||||
const pct = ((value - setting.min) / (setting.max - setting.min)) * 100
|
||||
return (
|
||||
<label className="lv-gui-slider" data-dragging={dragging}>
|
||||
<span className="lv-gui-slider-head">
|
||||
<span>{setting.name}</span>
|
||||
<span className="lv-gui-slider-value">
|
||||
{Math.round(value)}
|
||||
{setting.unit ?? ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lv-gui-slider-track" data-demo={`slider:${id}`}>
|
||||
<span className="lv-gui-slider-fill" style={{ width: `${pct}%` }} />
|
||||
<span className="lv-gui-slider-knob" data-demo={`knob:${id}`} style={{ left: `${pct}%` }} />
|
||||
<input
|
||||
type="range"
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
value={value}
|
||||
aria-label={setting.name}
|
||||
onChange={(e) => dispatch({ type: 'set', key: id, value: Number(e.target.value) })}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,197 +1,197 @@
|
|||
/* ClickGui replica. Colors come from themeToCssVars(); the typed @property registrations
|
||||
let a theme switch cross-fade (400ms) instead of snapping. */
|
||||
/* ClickGui replica — the "screen": world backdrop, HUD watermark, top pill bar, search button,
|
||||
hint lines and the demo cursor. Everything is sized in the mod's logical units: --u is one
|
||||
logical pixel of the in-game layout (panel = 115×240u), derived from the container width.
|
||||
Colors come from themeToCssVars(); typed @property registrations make a theme switch
|
||||
cross-fade (400ms) instead of snapping. */
|
||||
@property --gui-window-from { syntax: "<color>"; inherits: true; initial-value: #12191b; }
|
||||
@property --gui-window-to { syntax: "<color>"; inherits: true; initial-value: #12191b; }
|
||||
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
|
||||
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
|
||||
@property --gui-header-from { syntax: "<color>"; inherits: true; initial-value: #142226; }
|
||||
@property --gui-header-to { syntax: "<color>"; inherits: true; initial-value: #142226; }
|
||||
@property --gui-card-from { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
|
||||
@property --gui-card-to { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
|
||||
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
|
||||
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
|
||||
@property --gui-surface { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-surface-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-card-off { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
|
||||
@property --gui-text { syntax: "<color>"; inherits: true; initial-value: #fff; }
|
||||
@property --gui-text-muted { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
|
||||
@property --gui-accent { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
|
||||
@property --gui-accent-soft { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
|
||||
@property --gui-accent-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
|
||||
@property --gui-panel-tint { syntax: "<color>"; inherits: true; initial-value: #1d2224; }
|
||||
@property --gui-panel-stroke { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-split { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-row-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-row-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-row-text { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
|
||||
@property --gui-tab-left { syntax: "<color>"; inherits: true; initial-value: #151c1e; }
|
||||
@property --gui-tab-right { syntax: "<color>"; inherits: true; initial-value: #151c1e; }
|
||||
@property --gui-tab-active-left { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
|
||||
@property --gui-tab-active-right { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
|
||||
@property --gui-menu-left { syntax: "<color>"; inherits: true; initial-value: #12191b; }
|
||||
@property --gui-menu-right { syntax: "<color>"; inherits: true; initial-value: #12191b; }
|
||||
|
||||
.lv-gui {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
height: 452px;
|
||||
container-type: inline-size;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--gui-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-card-from,
|
||||
--gui-card-to, --gui-stroke-from, --gui-stroke-to, --gui-surface, --gui-surface-hover, --gui-card-off, --gui-text,
|
||||
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft;
|
||||
transition-property: --gui-window-from, --gui-window-to, --gui-stroke-from, --gui-stroke-to, --gui-text, --gui-text-muted,
|
||||
--gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-panel-tint, --gui-panel-stroke, --gui-split, --gui-row-hover,
|
||||
--gui-row-glow, --gui-row-text, --gui-tab-left, --gui-tab-right, --gui-tab-active-left, --gui-tab-active-right,
|
||||
--gui-menu-left, --gui-menu-right;
|
||||
transition-duration: 400ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
.lv-gui button { font: inherit; color: inherit; cursor: pointer; }
|
||||
.lv-gui-icon { width: 16px; height: 16px; flex: none; }
|
||||
.lv-gui-icon { width: 1em; height: 1em; flex: none; }
|
||||
|
||||
.lv-gui-bloom {
|
||||
position: absolute;
|
||||
inset: 8% 4% -2%;
|
||||
border-radius: 40px;
|
||||
background: radial-gradient(60% 60% at 50% 45%, var(--gui-accent-glow), transparent 70%);
|
||||
filter: blur(40px);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lv-gui-window {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
/* Narrow (< 900px): two panels, a taller screen. Wide: all four panels. */
|
||||
.lv-gui-stage {
|
||||
--u: calc(100cqw / 264);
|
||||
position: relative;
|
||||
aspect-ratio: 264 / 330;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(var(--gui-window-angle), var(--gui-window-from), var(--gui-window-to)) padding-box,
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent) 55%, transparent), var(--gui-stroke-from) 35%,
|
||||
var(--gui-stroke-to) 70%, color-mix(in srgb, var(--gui-accent) 30%, transparent)) border-box;
|
||||
box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.75), 0 0 0 1px rgb(0 0 0 / 0.35), 0 0 42px -6px var(--gui-accent-glow);
|
||||
backdrop-filter: blur(14px) saturate(1.2);
|
||||
border-radius: 14px;
|
||||
font-family: "SF Pro Display", -apple-system, "Inter", var(--font-sans), sans-serif;
|
||||
box-shadow: 0 30px 80px -24px rgb(0 0 0 / 0.8), 0 0 0 1px rgb(255 255 255 / 0.07), 0 0 60px -20px var(--gui-accent-glow);
|
||||
}
|
||||
@container (min-width: 900px) {
|
||||
.lv-gui-stage { --u: calc(100cqw / 560); aspect-ratio: 560 / 324; }
|
||||
}
|
||||
|
||||
.lv-gui-header {
|
||||
/* The world: Backdrop.tsx draws a night lake in SVG; `--gui-backdrop-image` (e.g. a real F1
|
||||
screenshot) paints over it when set. */
|
||||
.lv-gui-world { position: absolute; inset: 0; background: #020309; }
|
||||
.lv-gui-backdrop { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
.lv-gui-photo { position: absolute; inset: 0; background: var(--gui-backdrop-image, none) center / cover no-repeat; }
|
||||
.lv-gui-twinkle { animation: lv-gui-twinkle 3.2s ease-in-out infinite alternate; }
|
||||
.lv-gui-flake { animation: lv-gui-snow 12s linear infinite; }
|
||||
@keyframes lv-gui-twinkle { to { opacity: 0.15; } }
|
||||
@keyframes lv-gui-snow {
|
||||
0% { transform: translate(0, -20px); opacity: 0; }
|
||||
15%, 80% { opacity: 0.55; }
|
||||
100% { transform: translate(-14px, 70px); opacity: 0; }
|
||||
}
|
||||
|
||||
.lv-gui-watermark {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: calc(var(--u) * 3);
|
||||
top: calc(var(--u) * 3);
|
||||
margin: 0;
|
||||
padding: calc(var(--u) * 1) calc(var(--u) * 2.2);
|
||||
border-radius: calc(var(--u) * 1.6);
|
||||
background: rgb(22 22 26 / 0.82);
|
||||
font-size: calc(var(--u) * 5.4);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lv-gui-watermark b { color: var(--gui-accent); font-weight: 600; }
|
||||
.lv-gui-watermark span { margin-left: calc(var(--u) * 1.4); }
|
||||
|
||||
/* ClickGuiTabBar: clock pill + a two-segment switch whose accent thumb slides between tabs. */
|
||||
.lv-gui-bar {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: calc(var(--u) * 4);
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
flex: none;
|
||||
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 25%, transparent);
|
||||
gap: calc(var(--u) * 2.2);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.lv-gui-logo { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); font-weight: 700; font-size: 13px; letter-spacing: 0.02em; }
|
||||
.lv-gui-logo-mark { width: 16px; height: 16px; filter: drop-shadow(0 0 6px var(--gui-accent)); }
|
||||
.lv-gui-tabs { display: none; gap: 4px; margin-left: 10px; }
|
||||
.lv-gui-tab { position: relative; padding: 5px 10px; border-radius: 7px; color: var(--gui-text-muted); font-size: 12px; }
|
||||
.lv-gui-tab[data-active="true"] { color: var(--gui-text); background: color-mix(in srgb, var(--gui-accent) 14%, transparent); }
|
||||
.lv-gui-tab[data-active="true"]::after {
|
||||
content: ""; position: absolute; left: 10px; right: 10px; bottom: -8px; height: 2px; border-radius: 2px;
|
||||
background: var(--gui-accent); box-shadow: 0 0 10px var(--gui-accent);
|
||||
.lv-gui-clock {
|
||||
padding: calc(var(--u) * 1.3) calc(var(--u) * 3.4);
|
||||
border-radius: 999px;
|
||||
background: rgb(10 10 12 / 0.88);
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
font-size: calc(var(--u) * 4.4);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lv-gui-tabs {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: calc(var(--u) * 12);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--gui-tab-left), var(--gui-tab-right));
|
||||
border: 1px solid color-mix(in srgb, var(--gui-text) 14%, transparent);
|
||||
box-shadow: 0 calc(var(--u) * 2) calc(var(--u) * 7) rgb(0 0 0 / 0.35);
|
||||
}
|
||||
.lv-gui-tab {
|
||||
position: relative;
|
||||
min-width: calc(var(--u) * 33);
|
||||
padding: 0 calc(var(--u) * 5);
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--gui-text-muted) !important;
|
||||
font-size: calc(var(--u) * 5) !important;
|
||||
font-weight: 600 !important;
|
||||
transition: color 250ms;
|
||||
}
|
||||
.lv-gui-tab:hover { color: color-mix(in srgb, var(--gui-text-muted), var(--gui-text) 55%) !important; }
|
||||
.lv-gui-tab[aria-selected="true"] { color: var(--gui-text) !important; }
|
||||
.lv-gui-tab-thumb {
|
||||
position: absolute;
|
||||
top: calc(var(--u) * 1.2);
|
||||
bottom: calc(var(--u) * 1.2);
|
||||
left: calc(var(--u) * 1.2);
|
||||
width: calc(50% - var(--u) * 2.4);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--gui-tab-active-left), var(--gui-tab-active-right));
|
||||
border: 1px solid color-mix(in srgb, var(--gui-accent) 80%, white);
|
||||
box-shadow: 0 0 calc(var(--u) * 5) color-mix(in srgb, var(--gui-accent-soft) 45%, transparent);
|
||||
transition: left 380ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.lv-gui-stage[data-tab="settings"] .lv-gui-tab-thumb { left: calc(50% + var(--u) * 1.2); }
|
||||
|
||||
.lv-gui-search {
|
||||
margin-left: auto; display: flex; align-items: center; justify-content: center; width: 30px; height: 26px;
|
||||
border-radius: 7px; color: var(--gui-text-muted); background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: calc(var(--u) * 26);
|
||||
right: calc(var(--u) * 10);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: calc(var(--u) * 20);
|
||||
height: calc(var(--u) * 20);
|
||||
border-radius: calc(var(--u) * 6);
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
background: rgb(8 8 10 / 0.42);
|
||||
backdrop-filter: blur(calc(var(--u) * 4));
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
.lv-gui-search:hover { background: rgb(40 40 46 / 0.55); }
|
||||
.lv-gui-search-icon { width: calc(var(--u) * 9); height: calc(var(--u) * 9); color: var(--gui-text); }
|
||||
|
||||
.lv-gui-body { display: flex; min-height: 0; flex: 1; }
|
||||
.lv-gui-cats {
|
||||
display: flex; flex-direction: column; gap: 3px; width: 46px; flex: none; padding: 10px 6px;
|
||||
border-right: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 14%, transparent);
|
||||
background: color-mix(in srgb, black 12%, transparent);
|
||||
.lv-gui-screen {
|
||||
position: absolute;
|
||||
inset: calc(var(--u) * 50) 0 auto;
|
||||
height: calc(var(--u) * 240);
|
||||
animation: lv-gui-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.lv-gui-cat {
|
||||
position: relative; display: flex; align-items: center; justify-content: center; gap: 9px; height: 32px;
|
||||
padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--gui-text-muted) !important;
|
||||
transition: background-color 200ms, color 200ms;
|
||||
}
|
||||
.lv-gui-cat:hover { background: var(--gui-surface-hover); }
|
||||
.lv-gui-cat[data-active="true"] {
|
||||
color: var(--gui-text) !important;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--gui-accent) 24%, transparent), transparent);
|
||||
}
|
||||
.lv-gui-cat[data-active="true"]::before {
|
||||
content: ""; position: absolute; left: -6px; top: 7px; bottom: 7px; width: 3px; border-radius: 0 3px 3px 0;
|
||||
background: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent);
|
||||
}
|
||||
.lv-gui-cat[data-active="true"] .lv-gui-icon { color: var(--gui-accent); filter: drop-shadow(0 0 5px var(--gui-accent)); }
|
||||
.lv-gui-cat-name, .lv-gui-cat-count { display: none; }
|
||||
.lv-gui-cat-count { margin-left: auto; font-family: var(--font-code); font-size: 10px; opacity: 0.6; }
|
||||
.lv-gui-theme-dots { margin-top: auto; display: grid; grid-template-columns: 1fr; justify-items: center; gap: 7px; padding-top: 10px; }
|
||||
.lv-gui-theme-dot {
|
||||
width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgb(255 255 255 / 0.25); background: var(--dot);
|
||||
box-shadow: inset 0 0 0 3px rgb(0 0 0 / 0.25), 0 0 0 0 var(--dot-accent); transition: box-shadow 250ms, transform 250ms;
|
||||
}
|
||||
.lv-gui-theme-dot::after { content: ""; display: block; margin: 4px; height: 6px; border-radius: 50%; background: var(--dot-accent); }
|
||||
.lv-gui-theme-dot[data-active="true"] { box-shadow: 0 0 0 2px var(--dot-accent), 0 0 12px var(--dot-accent); transform: scale(1.1); }
|
||||
@keyframes lv-gui-in { from { opacity: 0; transform: translateY(calc(var(--u) * 8)) scale(0.97); } }
|
||||
|
||||
.lv-gui-main {
|
||||
position: relative; flex: 1; min-width: 0; overflow: hidden; padding: 10px;
|
||||
mask-image: linear-gradient(to bottom, #000 82%, transparent);
|
||||
.lv-gui-hints {
|
||||
position: absolute;
|
||||
left: calc(var(--u) * 8);
|
||||
right: calc(var(--u) * 8);
|
||||
bottom: calc(var(--u) * 5);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
columns: 2;
|
||||
column-gap: calc(var(--u) * 8);
|
||||
font-size: calc(var(--u) * 5.6);
|
||||
line-height: 1.4;
|
||||
color: rgb(255 255 255 / 0.78);
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lv-gui-grid { display: grid; grid-template-columns: 1fr; gap: 8px; align-items: start; animation: lv-gui-in 380ms ease both; }
|
||||
@keyframes lv-gui-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
|
||||
.lv-gui-card {
|
||||
position: relative; overflow: hidden; padding: 8px 9px 7px; border-radius: 6px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--gui-surface-hover) 70%, transparent), var(--gui-card-off)),
|
||||
rgb(8 12 14 / 0.55);
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent);
|
||||
box-shadow: 0 2px 9px rgb(0 0 0 / 0.28);
|
||||
transition: border-color 250ms, box-shadow 250ms, transform 200ms;
|
||||
}
|
||||
.lv-gui-card:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--gui-stroke-soft) 26%, transparent); }
|
||||
.lv-gui-card[data-on="true"] {
|
||||
background: linear-gradient(var(--gui-card-angle, 180deg), var(--gui-card-from), var(--gui-card-to)), rgb(8 12 14 / 0.45);
|
||||
border-color: color-mix(in srgb, var(--gui-accent) 40%, transparent);
|
||||
box-shadow: 0 2px 14px rgb(0 0 0 / 0.3), 0 0 18px -6px var(--gui-accent-glow);
|
||||
}
|
||||
.lv-gui-card-glow {
|
||||
position: absolute; left: -30px; top: -40px; width: 120px; height: 90px; border-radius: 50%;
|
||||
background: radial-gradient(closest-side, var(--gui-accent-soft), transparent); opacity: 0.12; pointer-events: none;
|
||||
transition: opacity 250ms;
|
||||
}
|
||||
.lv-gui-card[data-on="true"] .lv-gui-card-glow { opacity: 0.4; }
|
||||
.lv-gui-card-title { position: relative; margin: 0; font-family: var(--font-sans); font-weight: 600; font-size: 13px; color: var(--gui-text); opacity: 0.66; transition: opacity 250ms; }
|
||||
.lv-gui-card[data-on="true"] .lv-gui-card-title { opacity: 1; }
|
||||
.lv-gui-card-desc { position: relative; margin: 2px 0 0; font-size: 11px; color: var(--gui-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lv-gui-card-divider { height: 1px; margin: 7px -9px 6px; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent), transparent); }
|
||||
.lv-gui-card-foot { position: relative; display: flex; align-items: center; gap: 6px; }
|
||||
.lv-gui-bind { font-size: 10px; color: var(--gui-text-muted); }
|
||||
.lv-gui-bind span { font-family: var(--font-code); }
|
||||
.lv-gui-nosettings { display: none; }
|
||||
.lv-gui-expand {
|
||||
margin-left: auto; display: flex; width: 22px; height: 18px; align-items: center; justify-content: center;
|
||||
border: 0; border-radius: 5px; background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
|
||||
}
|
||||
.lv-gui-expand:hover { background: var(--gui-surface-hover); }
|
||||
.lv-gui-nosettings + .lv-gui-toggle, .lv-gui-bind + .lv-gui-toggle { margin-left: auto; }
|
||||
.lv-gui-chevron { width: 12px; height: 12px; color: var(--gui-text-muted); transition: transform 250ms; }
|
||||
.lv-gui-chevron[data-open="true"] { transform: rotate(180deg); color: var(--gui-accent); }
|
||||
.lv-gui-toggle {
|
||||
position: relative; width: 28px; height: 15px; flex: none; padding: 0; border-radius: 99px;
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent); background: rgb(0 0 0 / 0.35);
|
||||
transition: background-color 250ms, box-shadow 250ms, border-color 250ms;
|
||||
}
|
||||
.lv-gui-toggle-knob {
|
||||
position: absolute; top: 2px; left: 2px; width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--gui-text-muted); transition: transform 250ms cubic-bezier(0.3, 1.4, 0.5, 1), background-color 250ms;
|
||||
}
|
||||
.lv-gui-toggle[aria-checked="true"] {
|
||||
background: var(--gui-accent); border-color: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent-glow), 0 0 4px var(--gui-accent);
|
||||
}
|
||||
.lv-gui-toggle[aria-checked="true"] .lv-gui-toggle-knob { transform: translateX(13px); background: #fff; }
|
||||
|
||||
.lv-gui-settings { position: relative; display: flex; flex-direction: column; gap: 7px; margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent); animation: lv-gui-in 260ms ease both; }
|
||||
.lv-gui-slider { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--gui-text-muted); }
|
||||
.lv-gui-slider-head { display: flex; justify-content: space-between; }
|
||||
.lv-gui-slider-value { font-family: var(--font-code); color: var(--gui-text); }
|
||||
.lv-gui-slider-track { position: relative; display: block; height: 4px; border-radius: 4px; background: rgb(0 0 0 / 0.4); }
|
||||
.lv-gui-slider-fill { position: absolute; inset: 0 auto 0 0; border-radius: 4px; background: linear-gradient(90deg, var(--gui-accent-soft), var(--gui-accent)); box-shadow: 0 0 8px var(--gui-accent-glow); }
|
||||
.lv-gui-slider-knob { position: absolute; top: -4px; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 45%, transparent), 0 0 10px var(--gui-accent); }
|
||||
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-fill { transition: width 900ms cubic-bezier(0.4, 0, 0.3, 1); }
|
||||
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-knob { transition: left 900ms cubic-bezier(0.4, 0, 0.3, 1); }
|
||||
.lv-gui-slider input { position: absolute; inset: -8px 0; width: 100%; opacity: 0; cursor: pointer; margin: 0; }
|
||||
.lv-gui-check-row { display: flex; align-items: center; justify-content: space-between; padding: 0; border: 0; background: none; font-size: 11px; color: var(--gui-text-muted) !important; }
|
||||
.lv-gui-check { display: flex; width: 14px; height: 14px; border-radius: 4px; border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 40%, transparent); color: transparent; transition: all 200ms; }
|
||||
.lv-gui-check[data-on="true"] { background: var(--gui-accent); border-color: var(--gui-accent); color: #0b1012; box-shadow: 0 0 8px var(--gui-accent-glow); }
|
||||
.lv-gui-color { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgb(255 255 255 / 0.8); }
|
||||
.lv-gui-hints li { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.lv-gui-cursor {
|
||||
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; opacity: 0;
|
||||
|
|
@ -207,14 +207,7 @@
|
|||
}
|
||||
.lv-gui-cursor[data-pressed="true"] .lv-gui-cursor-ring { opacity: 0.9; transform: scale(1); box-shadow: 0 0 14px var(--gui-accent); }
|
||||
|
||||
@container (min-width: 440px) {
|
||||
.lv-gui-cats { width: 128px; }
|
||||
.lv-gui-cat { justify-content: flex-start; }
|
||||
.lv-gui-cat-name, .lv-gui-cat-count { display: inline; }
|
||||
.lv-gui-theme-dots { grid-template-columns: repeat(4, 1fr); }
|
||||
.lv-gui-grid { grid-template-columns: 1fr 1fr; }
|
||||
.lv-gui-nosettings { display: inline; margin-left: auto; opacity: 0.6; }
|
||||
}
|
||||
@container (min-width: 560px) {
|
||||
.lv-gui-tabs { display: flex; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lv-gui-screen, .lv-gui-twinkle, .lv-gui-flake { animation: none; }
|
||||
.lv-gui-tab-thumb { transition: none; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +1,67 @@
|
|||
// In-game catalogue shown by the ClickGui replica. Module, category and setting names are
|
||||
// the mod's own identifiers and stay untranslated; descriptions live in the clickgui i18n.
|
||||
// In-game catalogue shown by the ClickGui replica: the mod's `@ModuleInfo` display names grouped
|
||||
// the way `ModulesMenuCategory` groups them (MISC is shown as "Other"), sorted like the in-game
|
||||
// list. Names are the mod's own labels and stay untranslated.
|
||||
|
||||
export type Setting =
|
||||
| { kind: 'slider'; name: string; min: number; max: number; value: number; unit?: string }
|
||||
| { kind: 'check'; name: string; value: boolean }
|
||||
| { kind: 'color'; name: string; value: string }
|
||||
export type CategoryId = 'combat' | 'player' | 'visuals' | 'other'
|
||||
|
||||
export type GuiModule = { name: ModuleName; on?: boolean; settings?: Setting[] }
|
||||
|
||||
export type CategoryId = 'combat' | 'visuals' | 'player' | 'misc'
|
||||
export type GuiModule = { name: string; on?: boolean }
|
||||
|
||||
export type Category = { id: CategoryId; name: string; modules: GuiModule[] }
|
||||
|
||||
export type ModuleName =
|
||||
| 'PvpCooldowns' | 'Predictions' | 'TargetESP' | 'DamageTint' | 'NoFriendDamage' | 'CombatLog' | 'TPSSync'
|
||||
| 'ProjectilePuncher' | 'Trails' | 'JumpCircles' | 'HitBubbles' | 'AmbientParticles' | 'Wings' | 'ChinaHat'
|
||||
| 'ShaderSky' | 'MotionBlur' | 'PostFX' | 'KillEffect' | 'ThirdPerson' | 'AutoTool' | 'DeathCoords'
|
||||
| 'AutoRespawn' | 'ClickPearl' | 'LockSlot' | 'UseTracker' | 'NoDelay' | 'StreamerMode' | 'NameProtect'
|
||||
| 'HitSounds' | 'ItemScroller' | 'MessageFilter' | 'Optimize' | 'NoServerPack' | 'BetterMinecraft'
|
||||
|
||||
const slider = (name: string, value: number, max = 100, unit?: string): Setting => ({
|
||||
kind: 'slider', name, min: 0, max, value, unit,
|
||||
})
|
||||
const check = (name: string, value: boolean): Setting => ({ kind: 'check', name, value })
|
||||
const color = (name: string, value: string): Setting => ({ kind: 'color', name, value })
|
||||
const list = (names: string[], on: string[] = []): GuiModule[] =>
|
||||
[...names]
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase(), 'en'))
|
||||
.map((name) => (on.includes(name) ? { name, on: true } : { name }))
|
||||
|
||||
export const CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 'combat',
|
||||
name: 'Combat',
|
||||
modules: [
|
||||
{ name: 'TargetESP', on: true, settings: [slider('Size', 60), check('Pulse', true), color('Color', 'var(--gui-accent)')] },
|
||||
{ name: 'PvpCooldowns', on: true },
|
||||
{ name: 'Predictions', settings: [slider('Ticks', 6, 20), check('Show path', true)] },
|
||||
{ name: 'DamageTint', on: true, settings: [slider('Strength', 40), color('Tint', '#ff5a6e')] },
|
||||
{ name: 'NoFriendDamage' },
|
||||
{ name: 'TPSSync' },
|
||||
{ name: 'ProjectilePuncher' },
|
||||
{ name: 'CombatLog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'visuals',
|
||||
name: 'Visuals',
|
||||
modules: [
|
||||
{ name: 'Trails', settings: [slider('Length', 45), check('Rainbow', false), color('Color', 'var(--gui-accent)')] },
|
||||
{ name: 'JumpCircles', on: true, settings: [slider('Radius', 70), color('Color', 'var(--gui-accent)')] },
|
||||
{ name: 'HitBubbles', on: true },
|
||||
{ name: 'AmbientParticles', on: true, settings: [slider('Count', 120, 150), check('Glow', true)] },
|
||||
{ name: 'Wings' },
|
||||
{ name: 'ChinaHat', settings: [check('Gradient', true), color('Color', '#f2c14e')] },
|
||||
{ name: 'ShaderSky', on: true },
|
||||
{ name: 'MotionBlur', settings: [slider('Strength', 30)] },
|
||||
{ name: 'PostFX', on: true, settings: [check('Bloom', true), slider('Vignette', 25)] },
|
||||
{ name: 'KillEffect' },
|
||||
],
|
||||
modules: list(
|
||||
['CombatLog', 'DamageTint', 'No Friend Damage', 'Predictions', 'ProjectilePuncher', 'RubberHand', 'SpearAssist', 'TargetESP', 'TPSSync'],
|
||||
['No Friend Damage', 'Predictions', 'TargetESP'],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'player',
|
||||
name: 'Player',
|
||||
modules: [
|
||||
{ name: 'ThirdPerson', on: true, settings: [slider('Distance', 4, 12, 'm')] },
|
||||
{ name: 'AutoTool', on: true },
|
||||
{ name: 'DeathCoords', on: true },
|
||||
{ name: 'AutoRespawn' },
|
||||
{ name: 'ClickPearl' },
|
||||
{ name: 'LockSlot' },
|
||||
{ name: 'UseTracker' },
|
||||
{ name: 'NoDelay' },
|
||||
],
|
||||
modules: list(
|
||||
['Auto Accept', 'Auto Leave', 'Auto Respawn', 'AutoTool', 'ClickPearl', 'Death Coords', 'FakeLag', 'Lock Slot', 'NoDelay',
|
||||
'NoInteract', 'Tape Mouse', 'Third Person', 'Use Tracker'],
|
||||
['Auto Accept'],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'misc',
|
||||
name: 'Misc',
|
||||
modules: [
|
||||
{ name: 'StreamerMode', settings: [check('Hide coords', true), check('Hide IP', true)] },
|
||||
{ name: 'NameProtect', on: true },
|
||||
{ name: 'HitSounds', on: true, settings: [slider('Volume', 65)] },
|
||||
{ name: 'ItemScroller' },
|
||||
{ name: 'MessageFilter' },
|
||||
{ name: 'Optimize', on: true },
|
||||
{ name: 'NoServerPack' },
|
||||
{ name: 'BetterMinecraft' },
|
||||
],
|
||||
id: 'visuals',
|
||||
name: 'Visuals',
|
||||
modules: list(
|
||||
['AlwaysVisibleHand', 'AmbientParticles', 'Animations', 'AspectRatio', 'AtmoDawnFog', 'BackSword', 'BlockESP', 'BlockHighlight',
|
||||
'CameraClip', 'Chams', 'ChinaHat', 'Crosshair', 'CrystalHoles', 'DropESP', 'EndCrystal', 'ESP', 'FovControl', 'FPS Optimizer',
|
||||
'Freecam', 'FullBright', 'Gps', 'Hitboxes', 'HitBubbles', 'HitEffect', 'InvisESP', 'Item Holder', 'Item Physic', 'JumpCircles',
|
||||
'KillEffect', 'KineticLyrics', 'Light', 'Lightning Target ESP', 'MotionBlur', 'NameTags', 'NoRender', 'PostFX', 'Rain',
|
||||
'ReimaginedVisual', 'ShaderSky', 'Shulker Preview', 'SoundESP', 'TazikHat', 'TotemFX', 'Trails', 'Trinket', 'Trollface Mask',
|
||||
'ViewModel', 'Wet World', 'Wings', 'WorldParticles', 'WorldTweaks', 'Zoom'],
|
||||
['Chams'],
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
name: 'Other',
|
||||
modules: list(
|
||||
['Balancer', 'BetterMinecraft', 'ClickGUI', 'DefineTarget', 'FakePlayer', 'HitSounds', 'Item Scroller', 'MessageFilter',
|
||||
'Name Protect', 'No Server Pack', 'NoSound', 'Optimize', 'PvpCooldowns', 'ShitESP', 'Streamer Mode'],
|
||||
['ClickGUI'],
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
/** The ClickGui's top tab row — in-game labels. */
|
||||
export const TABS = ['Modules', 'Themes', 'Configs', 'HUD'] as const
|
||||
/** The pill bar's two segments — in-game labels. */
|
||||
export const TABS = ['modules', 'settings'] as const
|
||||
export type TabId = (typeof TABS)[number]
|
||||
|
||||
/** Presets offered as quick dots in the category column (and cycled by the demo). */
|
||||
export const QUICK_THEMES = ['classic', 'nitro', 'sunset', 'aurora'] as const
|
||||
/** Presets the scripted demo cycles through (all sit in the first rows of the theme grid). */
|
||||
export const QUICK_THEMES = ['amber', 'purple', 'blue', 'classic'] as const
|
||||
|
||||
export const settingKey = (module: string, setting: string) => `${module}.${setting}`
|
||||
|
||||
export const findModule = (name: string) => CATEGORIES.flatMap((c) => c.modules).find((m) => m.name === name)
|
||||
/** The settings sidebar, top to bottom; `null` draws a divider. Only "themes" has a page. */
|
||||
export const SIDEBAR = ['hud', 'ui', 'themes', null, 'layout', 'account', null, 'configs'] as const
|
||||
export type SidebarId = Exclude<(typeof SIDEBAR)[number], null>
|
||||
|
|
|
|||
56
frontend/src/features/clickgui/screens/ModulesScreen.tsx
Normal file
56
frontend/src/features/clickgui/screens/ModulesScreen.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Dispatch } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GuiAction, GuiState } from '../demo'
|
||||
import { Icon } from '../parts/Glyphs'
|
||||
import { type Category, CATEGORIES } from '../parts/modules'
|
||||
|
||||
type Props = { state: GuiState; dispatch: Dispatch<GuiAction> }
|
||||
|
||||
/** `ModulesMenuScreen`: the four liquid-glass category panels, side by side. */
|
||||
export function ModulesScreen({ state, dispatch }: Props) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
return (
|
||||
<div className="lv-gui-panels" role="list" aria-label={t('categories')}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<Panel key={c.id} category={c} state={state} dispatch={dispatch} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Panel({ category, state, dispatch }: { category: Category } & Props) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
const titleId = `lv-gui-panel-${category.id}`
|
||||
return (
|
||||
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
|
||||
<header className="lv-gui-panel-head">
|
||||
<h3 id={titleId} className="lv-gui-panel-title">
|
||||
{category.name}
|
||||
</h3>
|
||||
<Icon name={category.id} className="lv-gui-panel-icon" />
|
||||
</header>
|
||||
<ul className="lv-gui-rows">
|
||||
{category.modules.map((m) => {
|
||||
const on = Boolean(state.enabled[m.name])
|
||||
return (
|
||||
<li key={m.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="lv-gui-row"
|
||||
data-on={on}
|
||||
data-demo={`module:${m.name}`}
|
||||
aria-pressed={on}
|
||||
title={t('module', { name: m.name })}
|
||||
onClick={() => dispatch({ type: 'toggle', module: m.name })}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="lv-gui-row-name">{m.name}</span>
|
||||
{on && <Icon name="check" className="lv-gui-row-check" />}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
103
frontend/src/features/clickgui/screens/SettingsScreen.tsx
Normal file
103
frontend/src/features/clickgui/screens/SettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '../../themes/types'
|
||||
import { Icon } from '../parts/Glyphs'
|
||||
import { SIDEBAR } from '../parts/modules'
|
||||
|
||||
type Props = { activeId: string; onPick: (id: string) => void }
|
||||
|
||||
/** `MenuScreen` on its "Темы" page: icon sidebar + a two-column grid of theme cards. */
|
||||
export function SettingsScreen({ activeId, onPick }: Props) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
return (
|
||||
<div className="lv-gui-menu">
|
||||
<nav className="lv-gui-side" aria-label={t('settingsNav')}>
|
||||
{SIDEBAR.map((id, i) =>
|
||||
id === null ? (
|
||||
<span key={`split-${i}`} className="lv-gui-side-split" />
|
||||
) : (
|
||||
<span
|
||||
key={id}
|
||||
className="lv-gui-side-item"
|
||||
data-active={id === 'themes'}
|
||||
aria-current={id === 'themes' ? 'page' : undefined}
|
||||
aria-label={t(`sidebar.${id}`)}
|
||||
role="img"
|
||||
>
|
||||
{id === 'hud' || id === 'ui' ? t(`sidebar.${id}`) : <Icon name={id} className="lv-gui-side-icon" />}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
<div className="lv-gui-menu-body">
|
||||
<header className="lv-gui-menu-head">
|
||||
<Icon name="themes" className="lv-gui-menu-head-icon" />
|
||||
<h3>{t('themes')}</h3>
|
||||
<span className="lv-gui-menu-search" aria-hidden="true">
|
||||
{t('search')}
|
||||
<i />
|
||||
<Icon name="search" className="lv-gui-menu-search-icon" />
|
||||
</span>
|
||||
</header>
|
||||
<div className="lv-gui-themes" role="listbox" aria-label={t('themes')}>
|
||||
{PRESETS.map((p) => (
|
||||
<ThemeCard key={p.id} entry={p} selected={p.id === activeId} onPick={onPick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** `ThemeCardRenderer.fillSwatchPalette`: the 12 chips, each a start→end pair of palette slots. */
|
||||
const CHIPS: [keyof ThemeColors, keyof ThemeColors][] = [
|
||||
['windowBg', 'windowHeader'],
|
||||
['cardEnabled', 'accentSoft'],
|
||||
['windowHeader', 'windowStroke'],
|
||||
['surface', 'surfaceHover'],
|
||||
['surfaceHover', 'windowHeader'],
|
||||
['accent', 'accentSoft'],
|
||||
['accentSoft', 'strokeSoft'],
|
||||
['windowStroke', 'strokeSoft'],
|
||||
['strokeSoft', 'textMuted'],
|
||||
['textPrimary', 'textMuted'],
|
||||
['textMuted', 'surface'],
|
||||
['windowBg', 'accentSoft'],
|
||||
]
|
||||
|
||||
const chipGradient = (from: string, to: string, spec?: GradientSpec) => {
|
||||
const [a, b] = spec?.enabled ? [spec.start, spec.end] : [from, to]
|
||||
return `linear-gradient(135deg, ${argbToCss(a)}, ${argbToCss(b)})`
|
||||
}
|
||||
|
||||
function ThemeCard({ entry, selected, onPick }: { entry: ThemeEntry; selected: boolean; onPick: (id: string) => void }) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
const c = entry.theme
|
||||
const style = {
|
||||
'--card-accent': argbToCss(c.accent),
|
||||
'--card-accent-soft': argbToCss(c.accentSoft),
|
||||
'--card-bg': argbToCss(c.windowBg),
|
||||
} as CSSProperties
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={t('theme', { name: entry.name })}
|
||||
className="lv-gui-theme"
|
||||
data-selected={selected}
|
||||
data-demo={`theme:${entry.id}`}
|
||||
style={style}
|
||||
onClick={() => onPick(entry.id)}
|
||||
>
|
||||
<span className="lv-gui-theme-name">{entry.name}</span>
|
||||
<span className="lv-gui-theme-chips" aria-hidden="true">
|
||||
{CHIPS.map(([from, to], i) => {
|
||||
const spec = i === 0 ? entry.windowGradient : i === 1 ? entry.cardGradient : undefined
|
||||
return <i key={i} style={{ background: chipGradient(c[from], c[to], spec) }} />
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
248
frontend/src/features/clickgui/screens/screens.css
Normal file
248
frontend/src/features/clickgui/screens/screens.css
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/* The two ClickGui pages in the mod's logical units (--u, see parts/clickgui.css).
|
||||
Modules: ModulesMenuScreen — PANEL_W 115, PANEL_H 240, gap 14, HEADER_H 24, SEPARATOR_H 4,
|
||||
MODULE_ROW_H 20, radius 10, text inset 10. Settings: MenuScreen on the Темы page. */
|
||||
|
||||
.lv-gui-panels {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: calc(var(--u) * 14);
|
||||
height: 100%;
|
||||
}
|
||||
.lv-gui-panel[data-category="player"], .lv-gui-panel[data-category="other"] { display: none; }
|
||||
@container (min-width: 900px) {
|
||||
.lv-gui-panel[data-category="player"], .lv-gui-panel[data-category="other"] { display: block; }
|
||||
}
|
||||
|
||||
/* Liquid glass: near-opaque at the top, fading to a light veil so the world shows through. */
|
||||
.lv-gui-panel {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: calc(var(--u) * 115);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--u) * 10);
|
||||
border: 1px solid var(--gui-panel-stroke);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.05), transparent 12%),
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--gui-panel-tint) 92%, black) 0%,
|
||||
color-mix(in srgb, var(--gui-panel-tint) 78%, transparent) 22%,
|
||||
color-mix(in srgb, var(--gui-panel-tint) 46%, transparent) 55%,
|
||||
color-mix(in srgb, var(--gui-panel-tint) 22%, transparent) 100%);
|
||||
box-shadow: 0 calc(var(--u) * 5) calc(var(--u) * 18) rgb(0 0 0 / 0.28), inset 0 1px 0 rgb(255 255 255 / 0.1),
|
||||
inset 0 -1px 0 rgb(255 255 255 / 0.06);
|
||||
backdrop-filter: blur(calc(var(--u) * 5)) saturate(1.3);
|
||||
}
|
||||
.lv-gui-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: calc(var(--u) * 24);
|
||||
padding: calc(var(--u) * 1) calc(var(--u) * 10) 0;
|
||||
}
|
||||
.lv-gui-panel-head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(var(--u) * 8);
|
||||
right: calc(var(--u) * 8);
|
||||
top: calc(var(--u) * 24);
|
||||
height: max(1px, calc(var(--u) * 0.5));
|
||||
background: var(--gui-split);
|
||||
}
|
||||
.lv-gui-panel-title { margin: 0; font-family: inherit; font-size: calc(var(--u) * 9); font-weight: 600; letter-spacing: 0.005em; color: var(--gui-text); }
|
||||
.lv-gui-panel-icon { width: calc(var(--u) * 8); height: calc(var(--u) * 8); color: color-mix(in srgb, var(--gui-row-text) 90%, transparent); }
|
||||
|
||||
.lv-gui-rows {
|
||||
position: absolute;
|
||||
inset: calc(var(--u) * 27.5) calc(var(--u) * 3) calc(var(--u) * 0.5) 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--gui-text) 34%, transparent) color-mix(in srgb, var(--gui-text) 7%, transparent);
|
||||
}
|
||||
.lv-gui-rows::-webkit-scrollbar { width: calc(var(--u) * 2.5); }
|
||||
.lv-gui-rows::-webkit-scrollbar-track { background: color-mix(in srgb, var(--gui-text) 7%, transparent); border-radius: 9px; }
|
||||
.lv-gui-rows::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--gui-text) 34%, transparent); border-radius: 9px; }
|
||||
|
||||
.lv-gui-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(var(--u) * 108);
|
||||
height: calc(var(--u) * 20);
|
||||
padding: 0 calc(var(--u) * 17) 0 calc(var(--u) * 10);
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: calc(var(--u) * 8) !important;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--gui-row-text), var(--gui-text) 25%) !important;
|
||||
transition: color 250ms, padding 250ms;
|
||||
}
|
||||
.lv-gui-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(var(--u) * 2) calc(var(--u) * 3);
|
||||
border-radius: calc(var(--u) * 5);
|
||||
background: radial-gradient(90% 140% at 50% 50%, var(--gui-row-glow), transparent 75%), var(--gui-row-hover);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms;
|
||||
}
|
||||
.lv-gui-row::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(var(--u) * 3);
|
||||
right: calc(var(--u) * 3);
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--gui-text) 5%, transparent);
|
||||
}
|
||||
.lv-gui-row:hover::before { opacity: 0.8; }
|
||||
.lv-gui-row[data-on="true"] { padding-left: calc(var(--u) * 12); color: var(--gui-text) !important; }
|
||||
.lv-gui-row[data-on="true"]::before { opacity: 0.45; }
|
||||
.lv-gui-row[data-on="true"]:hover::before { opacity: 1; }
|
||||
.lv-gui-row-name { position: relative; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.lv-gui-row-check {
|
||||
position: absolute;
|
||||
right: calc(var(--u) * 11);
|
||||
top: calc(var(--u) * 7);
|
||||
width: calc(var(--u) * 6);
|
||||
height: calc(var(--u) * 6);
|
||||
color: var(--gui-text);
|
||||
stroke-width: 2.4;
|
||||
animation: lv-gui-check 260ms ease both;
|
||||
}
|
||||
@keyframes lv-gui-check { from { opacity: 0; transform: translateX(calc(var(--u) * 2)) scale(0.6); } }
|
||||
|
||||
/* Settings → Темы: a centered window with an icon sidebar and the theme-card grid. */
|
||||
.lv-gui-menu {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: grid;
|
||||
grid-template-columns: calc(var(--u) * 30) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
width: calc(var(--u) * 250);
|
||||
height: calc(var(--u) * 226);
|
||||
transform: translate(-50%, -50%);
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--u) * 5);
|
||||
border: 1px solid color-mix(in srgb, var(--gui-text) 13%, transparent);
|
||||
background: linear-gradient(90deg, var(--gui-menu-left), var(--gui-menu-right));
|
||||
box-shadow: 0 calc(var(--u) * 6) calc(var(--u) * 22) rgb(0 0 0 / 0.45);
|
||||
backdrop-filter: blur(calc(var(--u) * 6)) saturate(1.2);
|
||||
}
|
||||
@container (min-width: 900px) {
|
||||
.lv-gui-menu { width: calc(var(--u) * 336); height: calc(var(--u) * 196); }
|
||||
}
|
||||
.lv-gui-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(var(--u) * 4);
|
||||
border-right: 1px solid color-mix(in srgb, var(--gui-text) 9%, transparent);
|
||||
background: rgb(0 0 0 / 0.12);
|
||||
}
|
||||
.lv-gui-side-item {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: calc(var(--u) * 15);
|
||||
height: calc(var(--u) * 15);
|
||||
border-radius: 50%;
|
||||
font-size: calc(var(--u) * 4.4);
|
||||
font-weight: 500;
|
||||
color: var(--gui-text);
|
||||
}
|
||||
.lv-gui-side-item[data-active="true"] { background: color-mix(in srgb, var(--gui-text) 9%, transparent); }
|
||||
.lv-gui-side-icon { width: calc(var(--u) * 6.4); height: calc(var(--u) * 6.4); }
|
||||
.lv-gui-side-split { width: calc(var(--u) * 16); height: 1px; background: color-mix(in srgb, var(--gui-text) 12%, transparent); }
|
||||
|
||||
.lv-gui-menu-body { display: flex; flex-direction: column; min-width: 0; padding: calc(var(--u) * 5) calc(var(--u) * 4) 0 calc(var(--u) * 6); }
|
||||
.lv-gui-menu-head { display: flex; align-items: center; gap: calc(var(--u) * 2.6); height: calc(var(--u) * 14); flex: none; }
|
||||
.lv-gui-menu-head h3 { margin: 0; font-family: inherit; font-size: calc(var(--u) * 6.4); font-weight: 500; }
|
||||
.lv-gui-menu-head-icon { width: calc(var(--u) * 6.2); height: calc(var(--u) * 6.2); }
|
||||
.lv-gui-menu-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--u) * 2.4);
|
||||
margin-left: auto;
|
||||
padding: calc(var(--u) * 1.4) calc(var(--u) * 3);
|
||||
border-radius: calc(var(--u) * 2);
|
||||
background: rgb(0 0 0 / 0.22);
|
||||
font-size: calc(var(--u) * 5.6);
|
||||
color: var(--gui-text-muted);
|
||||
}
|
||||
.lv-gui-menu-search i { width: 1px; height: calc(var(--u) * 5.4); background: color-mix(in srgb, var(--gui-text) 22%, transparent); }
|
||||
.lv-gui-menu-search-icon { width: calc(var(--u) * 5); height: calc(var(--u) * 5); }
|
||||
|
||||
.lv-gui-themes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-auto-rows: calc(var(--u) * 31);
|
||||
gap: calc(var(--u) * 4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: calc(var(--u) * 2);
|
||||
padding: calc(var(--u) * 1) calc(var(--u) * 5) calc(var(--u) * 5) calc(var(--u) * 0.5);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--gui-text) 30%, transparent) transparent;
|
||||
}
|
||||
.lv-gui-theme {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: calc(var(--u) * 2.2);
|
||||
text-align: left;
|
||||
background:
|
||||
radial-gradient(120% 110% at 0% 0%, color-mix(in srgb, var(--card-accent) 16%, transparent), transparent 70%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--gui-panel-tint) 88%, white 4%), color-mix(in srgb, var(--card-bg) 70%, black));
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.05);
|
||||
transition: box-shadow 300ms, transform 200ms;
|
||||
}
|
||||
.lv-gui-theme:hover { transform: translateY(-1px); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.14); }
|
||||
.lv-gui-theme::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--u) * 10.5);
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgb(255 255 255 / 0.14), rgb(255 255 255 / 0.05));
|
||||
}
|
||||
.lv-gui-theme[data-selected="true"] {
|
||||
background:
|
||||
linear-gradient(160deg, color-mix(in srgb, var(--card-accent) 34%, transparent), color-mix(in srgb, var(--card-accent-soft) 12%, transparent)),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--gui-panel-tint) 88%, white 4%), color-mix(in srgb, var(--card-bg) 70%, black));
|
||||
box-shadow: inset 0 0 0 1px var(--card-accent), 0 0 calc(var(--u) * 7) color-mix(in srgb, var(--card-accent) 30%, transparent);
|
||||
}
|
||||
.lv-gui-theme[data-selected="true"]::before { background: linear-gradient(90deg, var(--card-accent), var(--card-accent-soft)); }
|
||||
.lv-gui-theme-name {
|
||||
position: absolute;
|
||||
left: calc(var(--u) * 4);
|
||||
top: calc(var(--u) * 2.4);
|
||||
font-size: calc(var(--u) * 5.3);
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
.lv-gui-theme[data-selected="true"] .lv-gui-theme-name { color: color-mix(in srgb, #fff 76%, var(--card-accent)); }
|
||||
.lv-gui-theme-chips {
|
||||
position: absolute;
|
||||
inset: calc(var(--u) * 11) calc(var(--u) * 4) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.lv-gui-theme-chips i {
|
||||
width: calc(var(--u) * 5.4);
|
||||
height: calc(var(--u) * 5.4);
|
||||
border-radius: calc(var(--u) * 1.3);
|
||||
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 0.12);
|
||||
transition: transform 200ms;
|
||||
}
|
||||
.lv-gui-theme-chips i:hover { transform: scale(1.18); }
|
||||
|
|
@ -1,47 +1,95 @@
|
|||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { argbToCss } from '../../themes/types'
|
||||
import { ActiveThemeProvider } from '../../themes/useActiveTheme'
|
||||
import { ClickGuiWindow } from '../ClickGuiWindow'
|
||||
import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo'
|
||||
|
||||
const renderGui = () => render(<ActiveThemeProvider applyToPage={false}><ClickGuiWindow /></ActiveThemeProvider>)
|
||||
const row = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}$`) })
|
||||
|
||||
test('reduced motion shows the finished demo frame with no cursor', () => {
|
||||
test('the Modules screen shows the four in-game category panels', () => {
|
||||
renderGui()
|
||||
expect(screen.getByRole('button', { name: 'Visuals' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('slider', { name: 'Length' })).toHaveValue('85')
|
||||
const panels = within(screen.getByRole('list', { name: 'Категории модулей' })).getAllByRole('listitem', { name: /./ })
|
||||
expect(panels).toHaveLength(4)
|
||||
for (const title of ['Combat', 'Player', 'Visuals', 'Other']) expect(screen.getByRole('heading', { name: title })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Modules' })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('cards are interactive: switch categories, toggle and expand', async () => {
|
||||
test('enabled modules carry a check mark; clicking a row toggles it', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGui()
|
||||
await user.click(screen.getByRole('button', { name: 'Misc' }))
|
||||
const toggle = screen.getByRole('switch', { name: 'Включить StreamerMode' })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'Настройки StreamerMode' }))
|
||||
const card = toggle.closest('article') as HTMLElement
|
||||
expect(within(card).getByRole('checkbox', { name: /Hide IP/ })).toHaveAttribute('aria-checked', 'true')
|
||||
const predictions = row('Predictions')
|
||||
expect(predictions).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(predictions.querySelector('.lv-gui-row-check')).not.toBeNull()
|
||||
const autoLeave = row('Auto Leave')
|
||||
expect(autoLeave.querySelector('.lv-gui-row-check')).toBeNull()
|
||||
await user.click(autoLeave)
|
||||
expect(autoLeave).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(autoLeave.querySelector('.lv-gui-row-check')).not.toBeNull()
|
||||
})
|
||||
|
||||
test('the scripted demo toggles Trails and loops back to the start', () => {
|
||||
test('reduced motion shows the finished demo frame', () => {
|
||||
renderGui()
|
||||
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(row('DamageTint')).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('Settings lists every preset as a theme card, and picking one re-themes the GUI', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGui()
|
||||
await user.click(screen.getByRole('tab', { name: 'Settings' }))
|
||||
expect(screen.getByRole('heading', { name: 'Темы' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('option')).toHaveLength(PRESETS.length)
|
||||
const nitro = PRESETS.find((p) => p.id === 'nitro')!
|
||||
await user.click(screen.getByRole('option', { name: 'Тема Nitro' }))
|
||||
expect(screen.getByRole('option', { name: 'Тема Nitro' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByTestId('clickgui-root').style.getPropertyValue('--gui-accent')).toBe(argbToCss(nitro.theme.accent))
|
||||
await user.click(screen.getByRole('tab', { name: 'Modules' }))
|
||||
expect(screen.getByRole('heading', { name: 'Combat' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('the scripted demo toggles modules, visits Settings and loops back', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: !q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
renderGui()
|
||||
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
|
||||
act(() => vi.advanceTimersByTime(3200))
|
||||
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
|
||||
act(() => vi.advanceTimersByTime(LOOP_MS))
|
||||
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'false')
|
||||
act(() => vi.advanceTimersByTime(2000))
|
||||
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'true')
|
||||
act(() => vi.advanceTimersByTime(3000))
|
||||
expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true')
|
||||
act(() => vi.advanceTimersByTime(LOOP_MS + 600 - 5000 + 500)) // into the next cycle, before its first click
|
||||
expect(screen.getByRole('tab', { name: 'Modules' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(row('AmbientParticles')).toHaveAttribute('aria-pressed', 'false')
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('demo timeline fits in the loop and the reducer resets', () => {
|
||||
expect(Math.max(...DEMO.map((s) => s.at))).toBeLessThan(LOOP_MS)
|
||||
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Trails' })
|
||||
expect(toggled.enabled.Trails).toBe(true)
|
||||
expect(guiReducer(toggled, { type: 'reset' }).enabled.Trails).toBe(false)
|
||||
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Chams' })
|
||||
expect(toggled.enabled.Chams).toBe(false)
|
||||
const settings = guiReducer(toggled, { type: 'tab', id: 'settings' })
|
||||
expect(guiReducer(settings, { type: 'reset' })).toEqual(initialGuiState())
|
||||
})
|
||||
|
||||
test('controlled mode: onPickTheme receives the card and the site theme stays put; backdrop is swappable', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onPickTheme = vi.fn()
|
||||
render(
|
||||
<ActiveThemeProvider applyToPage={false}>
|
||||
<ClickGuiWindow demo={false} onPickTheme={onPickTheme} backdrop="/shots/f1.png" />
|
||||
</ActiveThemeProvider>,
|
||||
)
|
||||
const root = screen.getByTestId('clickgui-root')
|
||||
expect(root.style.getPropertyValue('--gui-backdrop-image')).toBe('url("/shots/f1.png")')
|
||||
await user.click(screen.getByRole('tab', { name: 'Settings' }))
|
||||
const before = screen.getByRole('option', { selected: true })
|
||||
const accent = root.style.getPropertyValue('--gui-accent')
|
||||
await user.click(screen.getByRole('option', { name: 'Тема Legacy' }))
|
||||
expect(onPickTheme).toHaveBeenCalledWith('legacy')
|
||||
expect(screen.getByRole('option', { selected: true })).toBe(before)
|
||||
expect(root.style.getPropertyValue('--gui-accent')).toBe(accent)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import { argbToCss, type GradientSpec, type ThemeEntry, withAlpha } from '../themes/types'
|
||||
import { argbToCss, type GradientSpec, parseArgb, type ThemeEntry, withAlpha } from '../themes/types'
|
||||
|
||||
type CssVars = CSSProperties & Record<`--${string}`, string>
|
||||
|
||||
|
|
@ -46,6 +46,7 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
|
|||
'--gui-stroke-soft': argbToCss(t.strokeSoft),
|
||||
'--gui-accent-glow': withAlpha(t.accent, 0.45),
|
||||
}
|
||||
Object.assign(vars, derivedVars(entry))
|
||||
for (const [slot, key, flat] of GRADIENT_SLOTS) {
|
||||
const spec = entry[key]
|
||||
vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat])
|
||||
|
|
@ -55,3 +56,41 @@ export function themeToCssVars(entry: ThemeEntry): CssVars {
|
|||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
type Argb = { r: number; g: number; b: number; a: number }
|
||||
|
||||
/** `ClickGuiDrawHelpers.mixColor`: per-channel lerp of two packed ARGB colors, alpha included. */
|
||||
function mix(from: Argb, to: Argb, t: number): Argb {
|
||||
const l = (x: number, y: number) => Math.round(x * (1 - t) + y * t)
|
||||
return { r: l(from.r, to.r), g: l(from.g, to.g), b: l(from.b, to.b), a: l(from.a, to.a) }
|
||||
}
|
||||
|
||||
const opaque = (c: Argb): Argb => ({ ...c, a: 255 })
|
||||
const css = (c: Argb, alpha = c.a) => `rgba(${c.r}, ${c.g}, ${c.b}, ${Number((alpha / 255).toFixed(3))})`
|
||||
|
||||
/**
|
||||
* Colors the in-game renderers derive from the palette at draw time — `ModulesMenuStyle.syncTheme`
|
||||
* (category panels) and `ClickGuiTabBar.drawTabs` (the Modules/Settings pill) — so the replica
|
||||
* gets the very same tints instead of approximating them in CSS.
|
||||
*/
|
||||
function derivedVars(entry: ThemeEntry): CssVars {
|
||||
const p = Object.fromEntries(Object.entries(entry.theme).map(([k, v]) => [k, parseArgb(v)])) as Record<
|
||||
keyof ThemeEntry['theme'],
|
||||
Argb
|
||||
>
|
||||
const accent = opaque(p.accent)
|
||||
return {
|
||||
'--gui-panel-tint': css(mix(opaque(p.surface), accent, 0.07)),
|
||||
'--gui-panel-stroke': css(mix(opaque(p.strokeSoft), accent, 0.155), 76),
|
||||
'--gui-split': css(mix(opaque(p.strokeSoft), accent, 0.12), 34),
|
||||
'--gui-row-hover': css(mix(opaque(p.surfaceHover), accent, 0.125), 34),
|
||||
'--gui-row-glow': css(mix(opaque(p.accentSoft), accent, 0.42), 92),
|
||||
'--gui-row-text': css(opaque(p.textMuted), 210),
|
||||
'--gui-tab-left': css(mix(p.surface, p.windowBg, 0.34)),
|
||||
'--gui-tab-right': css(mix(p.surfaceHover, p.windowBg, 0.22)),
|
||||
'--gui-tab-active-left': css(mix(p.accentSoft, p.accent, 0.18)),
|
||||
'--gui-tab-active-right': css(mix(p.accent, p.textPrimary, 0.08)),
|
||||
'--gui-menu-left': css(mix(p.windowBg, p.surface, 0.18), 215),
|
||||
'--gui-menu-right': css(mix(p.windowBg, p.windowHeader, 0.3), 215),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
frontend/src/features/configs/api.ts
Normal file
18
frontend/src/features/configs/api.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
import type { SlotSummary } from './types'
|
||||
|
||||
export function listSlots(): Promise<SlotSummary[]> {
|
||||
return api.request('/configs')
|
||||
}
|
||||
|
||||
export function regenerateCode(slot: number): Promise<{ share_code: string }> {
|
||||
return api.request(`/configs/${slot}/regenerate-code`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function publish(slot: number, title: string, description: string): Promise<{ listing_id: string }> {
|
||||
return api.request(`/configs/${slot}/publish`, { method: 'POST', json: { title, description } })
|
||||
}
|
||||
|
||||
export function unpublish(slot: number): Promise<void> {
|
||||
return api.request(`/configs/${slot}/publish`, { method: 'DELETE' })
|
||||
}
|
||||
73
frontend/src/features/configs/components/PublishForm.tsx
Normal file
73
frontend/src/features/configs/components/PublishForm.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { TextField } from '../../../shared/ui/TextField'
|
||||
import { publish } from '../api'
|
||||
|
||||
const MAX_DESCRIPTION = 500
|
||||
|
||||
export function PublishForm({
|
||||
slot,
|
||||
onCancel,
|
||||
onPublished,
|
||||
}: {
|
||||
slot: number
|
||||
onCancel: () => void
|
||||
onPublished: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('configs')
|
||||
const client = useQueryClient()
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => publish(slot, title.trim(), description),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['configs'] })
|
||||
onPublished()
|
||||
},
|
||||
})
|
||||
|
||||
const invalid = touched && title.trim().length === 0
|
||||
|
||||
function submit() {
|
||||
setTouched(true)
|
||||
if (title.trim().length === 0 || title.trim().length > 64 || description.length > MAX_DESCRIPTION) return
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-shoal bg-abyss/60 p-3">
|
||||
<TextField
|
||||
label={t('publishForm.title')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
error={invalid ? t('publishForm.titleRequired') : undefined}
|
||||
maxLength={64}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="publish-description" className="text-sm text-frost">
|
||||
{t('publishForm.description')}
|
||||
</label>
|
||||
<textarea
|
||||
id="publish-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value.slice(0, MAX_DESCRIPTION))}
|
||||
rows={3}
|
||||
className="rounded-md border border-shoal bg-abyss px-3 py-2 text-snow focus:border-ice"
|
||||
/>
|
||||
<span className="self-end text-xs text-frost">{t('publishForm.counter', { count: description.length })}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" busy={mutation.isPending} onClick={submit}>
|
||||
{t('publishForm.submit')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={onCancel}>
|
||||
{t('publishForm.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
frontend/src/features/configs/components/SlotGrid.tsx
Normal file
21
frontend/src/features/configs/components/SlotGrid.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listSlots } from '../api'
|
||||
import type { SlotSummary } from '../types'
|
||||
import { SlotPanel } from './SlotPanel'
|
||||
|
||||
const SLOTS = [1, 2, 3, 4] as const
|
||||
|
||||
export function SlotGrid() {
|
||||
const { data } = useQuery({ queryKey: ['configs'], queryFn: listSlots })
|
||||
const bySlot = new Map<number, SlotSummary>((data ?? []).map((s) => [s.slot, s]))
|
||||
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-4 min-[640px]:grid-cols-2">
|
||||
{SLOTS.map((slot) => (
|
||||
<li key={slot}>
|
||||
<SlotPanel slot={slot} data={bySlot.get(slot) ?? null} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
124
frontend/src/features/configs/components/SlotPanel.tsx
Normal file
124
frontend/src/features/configs/components/SlotPanel.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { ShareCode } from '../../../shared/ui/ShareCode'
|
||||
import { regenerateCode, unpublish } from '../api'
|
||||
import type { SlotSummary } from '../types'
|
||||
import { PublishForm } from './PublishForm'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
|
||||
}
|
||||
|
||||
export function SlotPanel({ slot, data }: { slot: 1 | 2 | 3 | 4; data: SlotSummary | null }) {
|
||||
const { t } = useTranslation('configs')
|
||||
const client = useQueryClient()
|
||||
const titleId = useId()
|
||||
const [confirmingCode, setConfirmingCode] = useState(false)
|
||||
const [confirmingUnpublish, setConfirmingUnpublish] = useState(false)
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const regen = useMutation({
|
||||
mutationFn: () => regenerateCode(slot),
|
||||
onSuccess: (res) => {
|
||||
client.setQueryData<SlotSummary[]>(['configs'], (prev) =>
|
||||
(prev ?? []).map((s) => (s.slot === slot ? { ...s, share_code: res.share_code } : s)),
|
||||
)
|
||||
setConfirmingCode(false)
|
||||
},
|
||||
})
|
||||
|
||||
const unpub = useMutation({
|
||||
mutationFn: () => unpublish(slot),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['configs'] })
|
||||
setConfirmingUnpublish(false)
|
||||
setNotice(t('slot.unpublished'))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<article
|
||||
aria-labelledby={titleId}
|
||||
className="flex flex-col gap-3 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-4 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden="true" className="size-2 rounded-full bg-ice shadow-[0_0_6px_var(--color-ice)]" />
|
||||
<h3 id={titleId} className="font-display text-lg text-snow">
|
||||
{t('slot.heading', { slot })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{!data ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-frost">{t('slot.empty')}</p>
|
||||
<p className="font-code text-sm text-frost/80">{t('slot.emptyHint', { cmd: `%config save ${slot}` })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-display text-xl text-snow">{data.name}</p>
|
||||
<p className="text-sm text-frost">{t('slot.updated', { when: formatDate(data.updated_at) })}</p>
|
||||
<ShareCode code={data.share_code} label={t('slot.codeLabel')} />
|
||||
<p className="font-code text-xs text-frost/80">{t('slot.codeHint', { cmd: `%config load ${data.share_code}` })}</p>
|
||||
|
||||
{notice ? <Notice tone="info">{notice}</Notice> : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{confirmingCode ? (
|
||||
<>
|
||||
<span className="text-sm text-ember">{t('slot.newCodeConfirm')}</span>
|
||||
<Button variant="danger" busy={regen.isPending} onClick={() => regen.mutate()}>
|
||||
{t('slot.change')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={() => setConfirmingCode(false)}>
|
||||
{t('slot.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="quiet" onClick={() => setConfirmingCode(true)}>
|
||||
{t('slot.newCode')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!publishing &&
|
||||
(data.published ? (
|
||||
confirmingUnpublish ? (
|
||||
<>
|
||||
<span className="text-sm text-ember">{t('slot.unpublishConfirm')}</span>
|
||||
<Button variant="danger" busy={unpub.isPending} onClick={() => unpub.mutate()}>
|
||||
{t('slot.confirmUnpublish')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={() => setConfirmingUnpublish(false)}>
|
||||
{t('slot.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setConfirmingUnpublish(true)}>
|
||||
{t('slot.unpublish')}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button variant="primary" onClick={() => setPublishing(true)}>
|
||||
{t('slot.publish')}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{publishing ? (
|
||||
<PublishForm
|
||||
slot={slot}
|
||||
onCancel={() => setPublishing(false)}
|
||||
onPublished={() => {
|
||||
setPublishing(false)
|
||||
setNotice(t('slot.published'))
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
33
frontend/src/features/configs/i18n/en.ts
Normal file
33
frontend/src/features/configs/i18n/en.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const configsEn = {
|
||||
page: {
|
||||
title: 'My configs',
|
||||
leadBefore: 'Configs are saved from the mod with',
|
||||
leadAfter: '. This is where you get codes and publish.',
|
||||
},
|
||||
slot: {
|
||||
heading: 'Slot {{slot}}',
|
||||
empty: 'Empty',
|
||||
emptyHint: 'Save a config here from the mod: {{cmd}}',
|
||||
updated: 'Updated {{when}}',
|
||||
codeLabel: 'Code for friends',
|
||||
codeHint: 'A friend types this in-game: {{cmd}}',
|
||||
newCode: 'New code',
|
||||
newCodeConfirm: 'The old code will stop working. Change it?',
|
||||
change: 'Change',
|
||||
cancel: 'Cancel',
|
||||
publish: 'Publish',
|
||||
unpublish: 'Remove from showcase',
|
||||
unpublishConfirm: 'The config will disappear from the showcase. Remove it?',
|
||||
confirmUnpublish: 'Remove',
|
||||
published: 'Published. The config is now visible in the showcase.',
|
||||
unpublished: 'Removed from the showcase.',
|
||||
},
|
||||
publishForm: {
|
||||
title: 'Showcase title',
|
||||
description: 'Description',
|
||||
counter: '{{count}}/500',
|
||||
submit: 'Publish',
|
||||
cancel: 'Cancel',
|
||||
titleRequired: 'Title is required',
|
||||
},
|
||||
} as const
|
||||
33
frontend/src/features/configs/i18n/ru.ts
Normal file
33
frontend/src/features/configs/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const configsRu = {
|
||||
page: {
|
||||
title: 'Мои конфиги',
|
||||
leadBefore: 'Конфиги сохраняются из мода командой',
|
||||
leadAfter: '. Здесь коды и публикация.',
|
||||
},
|
||||
slot: {
|
||||
heading: 'Слот {{slot}}',
|
||||
empty: 'Пусто',
|
||||
emptyHint: 'Сохрани сюда конфиг из мода: {{cmd}}',
|
||||
updated: 'Обновлён {{when}}',
|
||||
codeLabel: 'Код для друзей',
|
||||
codeHint: 'Друг вводит в игре: {{cmd}}',
|
||||
newCode: 'Новый код',
|
||||
newCodeConfirm: 'Старый код перестанет работать. Сменить?',
|
||||
change: 'Сменить',
|
||||
cancel: 'Отмена',
|
||||
publish: 'Опубликовать',
|
||||
unpublish: 'Убрать с витрины',
|
||||
unpublishConfirm: 'Конфиг исчезнет с витрины. Убрать?',
|
||||
confirmUnpublish: 'Убрать',
|
||||
published: 'Опубликовано. Конфиг виден на витрине.',
|
||||
unpublished: 'Убрано с витрины.',
|
||||
},
|
||||
publishForm: {
|
||||
title: 'Название на витрине',
|
||||
description: 'Описание',
|
||||
counter: '{{count}}/500',
|
||||
submit: 'Опубликовать',
|
||||
cancel: 'Отмена',
|
||||
titleRequired: 'Название обязательно',
|
||||
},
|
||||
} as const
|
||||
64
frontend/src/features/configs/tests/configs.test.tsx
Normal file
64
frontend/src/features/configs/tests/configs.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { SlotGrid } from '../components/SlotGrid'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
const filled = { slot: 2, name: 'pvp', updated_at: '2026-09-24T10:00:00Z', share_code: '7KQ3M9XA', published: false }
|
||||
|
||||
test('always shows four slots', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401), 'GET /configs': () => json([filled]) })
|
||||
renderApp([{ path: '/', element: <SlotGrid /> }])
|
||||
expect(await screen.findByText('7KQ3M9XA')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('article')).toHaveLength(4)
|
||||
expect(screen.getAllByText('Пусто')).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('regenerating a code needs confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
let code = '7KQ3M9XA'
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /configs': () => json([{ ...filled, share_code: code }]),
|
||||
'POST /configs/2/regenerate-code': () => {
|
||||
code = 'NEWC0DE2'
|
||||
return json({ share_code: code })
|
||||
},
|
||||
})
|
||||
renderApp([{ path: '/', element: <SlotGrid /> }])
|
||||
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
|
||||
await user.click(within(panel).getByRole('button', { name: 'Новый код' }))
|
||||
expect(within(panel).getByText(/Старый код перестанет работать/)).toBeInTheDocument()
|
||||
await user.click(within(panel).getByRole('button', { name: 'Сменить' }))
|
||||
expect(await within(panel).findByText('NEWC0DE2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('publish requires a title and sends the form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetch = mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /configs': () => json([filled]),
|
||||
'POST /configs/2/publish': () => json({ listing_id: 'l1' }),
|
||||
})
|
||||
renderApp([{ path: '/', element: <SlotGrid /> }])
|
||||
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
|
||||
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
|
||||
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
|
||||
expect(within(panel).getByLabelText('Название на витрине')).toHaveAttribute('aria-invalid', 'true')
|
||||
await user.type(within(panel).getByLabelText('Название на витрине'), 'Лучший PvP')
|
||||
await user.click(within(panel).getByRole('button', { name: 'Опубликовать' }))
|
||||
expect(await within(panel).findByText(/Опубликовано/)).toBeInTheDocument()
|
||||
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/publish'))?.[1]?.body
|
||||
expect(JSON.parse(String(body))).toEqual({ title: 'Лучший PvP', description: '' })
|
||||
})
|
||||
|
||||
test('published slot offers to remove from showcase', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /configs': () => json([{ ...filled, published: true }]),
|
||||
})
|
||||
renderApp([{ path: '/', element: <SlotGrid /> }])
|
||||
const panel = (await screen.findByText('pvp')).closest('article') as HTMLElement
|
||||
expect(within(panel).getByRole('button', { name: 'Убрать с витрины' })).toBeInTheDocument()
|
||||
})
|
||||
7
frontend/src/features/configs/types.ts
Normal file
7
frontend/src/features/configs/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export type SlotSummary = {
|
||||
slot: 1 | 2 | 3 | 4
|
||||
name: string
|
||||
updated_at: string
|
||||
share_code: string
|
||||
published: boolean
|
||||
}
|
||||
62
frontend/src/features/device-link/LinkDeviceForm.tsx
Normal file
62
frontend/src/features/device-link/LinkDeviceForm.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { describeError } from '../../shared/api/errors'
|
||||
import { Button } from '../../shared/ui/Button'
|
||||
import { Notice } from '../../shared/ui/Notice'
|
||||
import { confirmDevice, normalizeUserCode } from './api'
|
||||
|
||||
/** The device-code confirmation form: styled like typing a command into the in-game chat bar. */
|
||||
export function LinkDeviceForm() {
|
||||
const { t } = useTranslation('deviceLink')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState<string>()
|
||||
const mutation = useMutation({
|
||||
mutationFn: (userCode: string) => confirmDevice(userCode),
|
||||
onError: (err) => setError(describeError(tc, err, { 404: t('form.expired') })),
|
||||
})
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(undefined)
|
||||
mutation.mutate(normalizeUserCode(code))
|
||||
}
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
return <Notice tone="info">{t('form.success')}</Notice>
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="device-code" className="text-sm text-frost">
|
||||
{t('form.codeLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-md border border-shoal bg-abyss px-3 py-2 focus-within:border-ice">
|
||||
<span aria-hidden="true" className="font-code text-ice">
|
||||
%
|
||||
</span>
|
||||
<input
|
||||
id="device-code"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
inputMode="text"
|
||||
maxLength={9}
|
||||
placeholder="ABCD-1234"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full bg-transparent font-code text-lg uppercase tracking-widest text-snow placeholder:text-frost/40 focus:outline-none"
|
||||
/>
|
||||
<span aria-hidden="true" className="font-code animate-pulse text-ice">
|
||||
▌
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={mutation.isPending}>
|
||||
{t('form.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
13
frontend/src/features/device-link/api.ts
Normal file
13
frontend/src/features/device-link/api.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
|
||||
/** Uppercases, strips spaces, and inserts the dash after 4 chars if missing (`abcd1234` -> `ABCD-1234`). */
|
||||
export function normalizeUserCode(raw: string): string {
|
||||
const cleaned = raw.trim().toUpperCase().replace(/\s+/g, '').replace(/-/g, '')
|
||||
if (cleaned.length <= 4) return cleaned
|
||||
return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`
|
||||
}
|
||||
|
||||
/** Confirms a device code the mod displayed, linking that game session to the signed-in account. */
|
||||
export async function confirmDevice(userCode: string): Promise<void> {
|
||||
await api.request('/device/confirm', { method: 'POST', json: { user_code: userCode } })
|
||||
}
|
||||
20
frontend/src/features/device-link/i18n/en.ts
Normal file
20
frontend/src/features/device-link/i18n/en.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { deviceLinkRu } from './ru'
|
||||
|
||||
export const deviceLinkEn = {
|
||||
page: {
|
||||
title: 'Link the game',
|
||||
lead: "No password needed in the mod: it shows a short code, and you confirm it here.",
|
||||
},
|
||||
form: {
|
||||
codeLabel: 'Code from the game',
|
||||
submit: 'Link',
|
||||
success: 'Game linked. You can go back to Minecraft, the mod is already signed in.',
|
||||
expired: "Code not found or expired. Codes last 10 minutes, grab a new one in the mod.",
|
||||
},
|
||||
steps: {
|
||||
one: 'Open the ClickGui in-game and switch to the account tab.',
|
||||
two: 'The mod shows a short code, type it here.',
|
||||
three: 'Confirm, the mod signs itself into your account.',
|
||||
},
|
||||
} satisfies Translation<typeof deviceLinkRu>
|
||||
17
frontend/src/features/device-link/i18n/ru.ts
Normal file
17
frontend/src/features/device-link/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export const deviceLinkRu = {
|
||||
page: {
|
||||
title: 'Привязать игру',
|
||||
lead: 'Пароль в мод вводить не нужно: мод показывает короткий код, ты подтверждаешь его здесь.',
|
||||
},
|
||||
form: {
|
||||
codeLabel: 'Код из игры',
|
||||
submit: 'Привязать',
|
||||
success: 'Игра привязана. Можно возвращаться в Minecraft, мод уже вошёл в аккаунт.',
|
||||
expired: 'Код не найден или устарел. Коды живут 10 минут, возьми новый в моде.',
|
||||
},
|
||||
steps: {
|
||||
one: 'Открой ClickGui в игре и включи вкладку аккаунта.',
|
||||
two: 'Мод покажет короткий код, набери его здесь.',
|
||||
three: 'Подтверди, мод сам войдёт в твой аккаунт.',
|
||||
},
|
||||
} as const
|
||||
38
frontend/src/features/device-link/tests/link.test.tsx
Normal file
38
frontend/src/features/device-link/tests/link.test.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { LinkDeviceForm } from '../LinkDeviceForm'
|
||||
import { normalizeUserCode } from '../api'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('normalizes typed codes', () => {
|
||||
expect(normalizeUserCode(' abcd1234 ')).toBe('ABCD-1234')
|
||||
expect(normalizeUserCode('abcd-1234')).toBe('ABCD-1234')
|
||||
})
|
||||
|
||||
test('confirms a code and shows success', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetch = mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'POST /device/confirm': () => new Response(null, { status: 200 }),
|
||||
})
|
||||
renderApp([{ path: '/', element: <LinkDeviceForm /> }])
|
||||
await user.type(await screen.findByLabelText('Код из игры'), 'abcd1234')
|
||||
await user.click(screen.getByRole('button', { name: 'Привязать' }))
|
||||
expect(await screen.findByText(/Игра привязана/)).toBeInTheDocument()
|
||||
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/device/confirm'))?.[1]?.body
|
||||
expect(body).toBe('{"user_code":"ABCD-1234"}')
|
||||
})
|
||||
|
||||
test('expired code explains what to do', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'POST /device/confirm': () => json({ error: 'unknown or expired user_code' }, 404),
|
||||
})
|
||||
renderApp([{ path: '/', element: <LinkDeviceForm /> }])
|
||||
await user.type(await screen.findByLabelText('Код из игры'), 'ZZZZ-ZZZZ')
|
||||
await user.click(screen.getByRole('button', { name: 'Привязать' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Коды живут 10 минут')
|
||||
})
|
||||
|
|
@ -8,17 +8,17 @@ export const downloadEn = {
|
|||
versionHint: 'v{version} · {size}',
|
||||
page: {
|
||||
title: 'Download LoVisual',
|
||||
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file — no installers, no ads.',
|
||||
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file: no installers, no ads.',
|
||||
reqTitle: 'What you need',
|
||||
reqMc: 'Minecraft',
|
||||
reqLoader: 'Fabric Loader',
|
||||
reqApi: 'Fabric API',
|
||||
reqOptionalTitle: 'Optional, for maximum FPS',
|
||||
reqOptional: 'Sodium and Iris — the mod does not require them but plays nicely.',
|
||||
reqOptional: 'Sodium and Iris: the mod does not require them but plays nicely.',
|
||||
installTitle: 'Install in three steps',
|
||||
step1: 'Download lovisual.jar with the button above.',
|
||||
step2: 'Drop the file into your mods folder (create it if it is missing).',
|
||||
step3: 'Launch Minecraft with the Fabric profile — the mod is active right away.',
|
||||
step3: 'Launch Minecraft with the Fabric profile, the mod is active right away.',
|
||||
},
|
||||
notes: {
|
||||
title: "What's new",
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ export const downloadRu = {
|
|||
versionHint: 'v{version} · {size}',
|
||||
page: {
|
||||
title: 'Скачать LoVisual',
|
||||
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл — без установщиков и без рекламы.',
|
||||
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл: без установщиков и без рекламы.',
|
||||
reqTitle: 'Что понадобится',
|
||||
reqMc: 'Minecraft',
|
||||
reqLoader: 'Fabric Loader',
|
||||
reqApi: 'Fabric API',
|
||||
reqOptionalTitle: 'По желанию для максимального FPS',
|
||||
reqOptional: 'Sodium и Iris — мод не требует их, но дружит с ними.',
|
||||
reqOptional: 'Sodium и Iris: мод не требует их, но дружит с ними.',
|
||||
installTitle: 'Установка в три шага',
|
||||
step1: 'Скачай lovisual.jar кнопкой выше.',
|
||||
step2: 'Положи файл в папку mods (создай её, если нет).',
|
||||
step3: 'Запусти Minecraft с профилем Fabric — мод активен сразу.',
|
||||
step3: 'Запусти Minecraft с профилем Fabric, мод активен сразу.',
|
||||
},
|
||||
notes: {
|
||||
title: 'Что нового',
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function CommandHero() {
|
|||
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28">
|
||||
<h2 className="text-[2rem] leading-[1.1] sm:text-[2.8rem]">
|
||||
{t('cloud.headlinePart1')}
|
||||
{' '}— <br className="hidden lg:inline" />
|
||||
{' '}<br className="hidden lg:inline" />
|
||||
<span className="text-ice">{t('cloud.headlinePart2')}</span>
|
||||
</h2>
|
||||
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import { motion, useMotionValue, useSpring } from 'motion/react'
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
|
||||
|
||||
const MAX_DEG = 8
|
||||
const REST = { x: 4, y: -6 }
|
||||
const SPRING = { stiffness: 120, damping: 16, mass: 0.6 }
|
||||
|
||||
const clamp = (v: number) => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
|
||||
|
||||
/** Pointer-driven 3D tilt (max 8°) on a spring; rests at a slight angle for depth. */
|
||||
export function Tilt({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const rx = useMotionValue(REST.x)
|
||||
const ry = useMotionValue(REST.y)
|
||||
const rotateX = useSpring(rx, SPRING)
|
||||
const rotateY = useSpring(ry, SPRING)
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion()) return
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const el = ref.current
|
||||
if (!el || e.pointerType !== 'mouse') return
|
||||
const r = el.getBoundingClientRect()
|
||||
const nx = (e.clientX - (r.left + r.width / 2)) / (window.innerWidth / 2)
|
||||
const ny = (e.clientY - (r.top + r.height / 2)) / (window.innerHeight / 2)
|
||||
ry.set(clamp(nx * MAX_DEG))
|
||||
rx.set(clamp(-ny * MAX_DEG))
|
||||
}
|
||||
const onLeave = () => {
|
||||
rx.set(REST.x)
|
||||
ry.set(REST.y)
|
||||
}
|
||||
window.addEventListener('pointermove', onMove, { passive: true })
|
||||
document.documentElement.addEventListener('pointerleave', onLeave)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
document.documentElement.removeEventListener('pointerleave', onLeave)
|
||||
}
|
||||
}, [rx, ry])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} style={{ perspective: 1400 }}>
|
||||
<motion.div style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}>{children}</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,12 +15,12 @@ export const landingEn = {
|
|||
visuals: 'for visuals',
|
||||
themes: 'built-in themes',
|
||||
},
|
||||
tryIt: 'This is the real interface — hover and click around',
|
||||
tryIt: 'This is the real interface: hover and click around',
|
||||
},
|
||||
themeStrip: {
|
||||
eyebrow: 'Themes',
|
||||
title: 'Hundreds of shades. Yours is one.',
|
||||
lead: 'Every built-in theme, straight from the mod. Click one — the ClickGui and the whole site recolor on the fly.',
|
||||
lead: 'Every built-in theme, straight from the mod. Click one: the ClickGui and the whole site recolor on the fly.',
|
||||
cta: 'Build your own theme',
|
||||
current: 'Now',
|
||||
swatches: 'Built-in themes',
|
||||
|
|
@ -39,14 +39,14 @@ export const landingEn = {
|
|||
howItWorks: {
|
||||
slotsTitle: 'Four slots in the cloud',
|
||||
slotsDesc:
|
||||
'Save up to four sets of settings and load any of them on another computer — every slot has a code a friend can load too.',
|
||||
'Save up to four sets of settings and load any of them on another computer: every slot has a code a friend can load too.',
|
||||
slotsAriaLabel: 'Example slots',
|
||||
emptySlot: 'empty',
|
||||
slotNamePvp: 'pvp',
|
||||
slotNameStream: 'stream',
|
||||
slotNameLazy: 'lazy',
|
||||
linkTitle: 'Linking the game',
|
||||
linkStep1: 'Open the account tab in the mod — a code will appear there.',
|
||||
linkStep1: 'Open the account tab in the mod, a code will appear there.',
|
||||
linkStep2: 'Enter the code on the "Link game" page.',
|
||||
linkStep3: "Done: the mod signs in to your account by itself, no password needed.",
|
||||
showcaseText: 'Any config from the showcase can be copied into a free slot with one button.',
|
||||
|
|
@ -56,7 +56,7 @@ export const landingEn = {
|
|||
eyebrow: 'HUD',
|
||||
title: 'Arrange the widgets like in game',
|
||||
lead: 'The same widgets the mod draws: FPS, coordinates, module list, keys and ping. Drag them with the mouse or arrow keys.',
|
||||
caption: 'Drag it — just like in game.',
|
||||
caption: 'Drag it, just like in game.',
|
||||
reset: 'Reset',
|
||||
widgetFps: 'FPS',
|
||||
widgetCoords: 'Coordinates',
|
||||
|
|
@ -69,7 +69,7 @@ export const landingEn = {
|
|||
wall: {
|
||||
eyebrow: 'Modules',
|
||||
title: '88 modules. 52 are about looks.',
|
||||
lead: 'The whole category wall. Hover a visuals module — I\'ll tell you what it does.',
|
||||
lead: 'The whole category wall. Hover a visuals module: I\'ll tell you what it does.',
|
||||
columnsAria: 'Module categories',
|
||||
},
|
||||
showcase: {
|
||||
|
|
@ -95,7 +95,7 @@ export const landingEn = {
|
|||
q5: 'How do I move settings to another computer?',
|
||||
a5: 'Save a config to a cloud slot and load it by code on any other PC.',
|
||||
downloadTitle: 'Ready to try it?',
|
||||
downloadLead: 'One .jar straight from GitHub — just drop it into your mods folder.',
|
||||
downloadLead: 'One .jar straight from GitHub, just drop it into your mods folder.',
|
||||
downloadCta: 'Download for free',
|
||||
downloadNote: 'Minecraft 26.2 · Fabric · the latest release',
|
||||
releaseLink: 'Releases and versions',
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ export const landingRu = {
|
|||
visuals: 'про визуал',
|
||||
themes: 'встроенных тем',
|
||||
},
|
||||
tryIt: 'Это настоящий интерфейс — наведи и покликай',
|
||||
tryIt: 'Это настоящий интерфейс: наведи и покликай',
|
||||
},
|
||||
themeStrip: {
|
||||
eyebrow: 'Темы',
|
||||
title: 'Сотни оттенков. Твой — один.',
|
||||
lead: 'Все встроенные темы прямо из мода. Нажми на любую — ClickGui и весь сайт перекрасятся на лету.',
|
||||
title: 'Сотни оттенков. Твой один.',
|
||||
lead: 'Все встроенные темы прямо из мода. Нажми на любую: ClickGui и весь сайт перекрасятся на лету.',
|
||||
cta: 'Собрать свою тему',
|
||||
current: 'Сейчас',
|
||||
swatches: 'Встроенные темы',
|
||||
|
|
@ -36,14 +36,14 @@ export const landingRu = {
|
|||
howItWorks: {
|
||||
slotsTitle: 'Четыре слота в облаке',
|
||||
slotsDesc:
|
||||
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере — у каждого слота есть код, по которому его может загрузить и друг.',
|
||||
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере: у каждого слота есть код, по которому его может загрузить и друг.',
|
||||
slotsAriaLabel: 'Пример слотов',
|
||||
emptySlot: 'пусто',
|
||||
slotNamePvp: 'pvp',
|
||||
slotNameStream: 'стрим',
|
||||
slotNameLazy: 'ленивый',
|
||||
linkTitle: 'Привязка игры',
|
||||
linkStep1: 'В моде открой вкладку аккаунта — там появится код.',
|
||||
linkStep1: 'В моде открой вкладку аккаунта, там появится код.',
|
||||
linkStep2: 'Введи код на странице «Привязать игру».',
|
||||
linkStep3: 'Готово: мод входит в аккаунт сам, пароль ему не нужен.',
|
||||
showcaseText: 'Любой конфиг из витрины можно скопировать в свободный слот одной кнопкой.',
|
||||
|
|
@ -53,7 +53,7 @@ export const landingRu = {
|
|||
eyebrow: 'HUD',
|
||||
title: 'Расставь элементы как в игре',
|
||||
lead: 'Те же виджеты, что рисует мод: FPS, координаты, список модулей, клавиши и пинг. Таскай их мышкой или стрелками.',
|
||||
caption: 'Перетащи — так же, как в игре.',
|
||||
caption: 'Перетащи так же, как в игре.',
|
||||
reset: 'Сбросить',
|
||||
widgetFps: 'FPS',
|
||||
widgetCoords: 'Координаты',
|
||||
|
|
@ -61,12 +61,12 @@ export const landingRu = {
|
|||
widgetKeys: 'Нажатия клавиш',
|
||||
widgetPing: 'Пинг',
|
||||
unitMs: 'мс',
|
||||
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки — двигать.',
|
||||
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки: двигать.',
|
||||
},
|
||||
wall: {
|
||||
eyebrow: 'Модули',
|
||||
title: '88 модулей. 52 — про красоту.',
|
||||
lead: 'Вся витрина категорий. Наведи на визуальный модуль — расскажу, что он делает.',
|
||||
title: '88 модулей. 52 про красоту.',
|
||||
lead: 'Вся витрина категорий. Наведи на визуальный модуль: расскажу, что он делает.',
|
||||
columnsAria: 'Категории модулей',
|
||||
},
|
||||
showcase: {
|
||||
|
|
@ -86,13 +86,13 @@ export const landingRu = {
|
|||
q2: 'Какие версии Minecraft поддерживаются?',
|
||||
a2: 'Minecraft 26.2 на загрузчике Fabric.',
|
||||
q3: 'Нужен ли аккаунт?',
|
||||
a3: 'Для самого мода — нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
|
||||
a3: 'Для самого мода: нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
|
||||
q4: 'Это легитно? Забанят ли?',
|
||||
a4: 'LoVisual — визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
|
||||
a4: 'LoVisual, визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
|
||||
q5: 'Как перенести настройки на другой компьютер?',
|
||||
a5: 'Сохрани конфиг в облачный слот и загрузи его по коду на любом другом ПК.',
|
||||
downloadTitle: 'Готов попробовать?',
|
||||
downloadLead: 'Один .jar прямо с GitHub — просто положи его в папку mods.',
|
||||
downloadLead: 'Один .jar прямо с GitHub, просто положи его в папку mods.',
|
||||
downloadCta: 'Скачать бесплатно',
|
||||
downloadNote: 'Minecraft 26.2 · Fabric · самый свежий релиз',
|
||||
releaseLink: 'Список релизов и версий',
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { DOWNLOAD_URL, GITHUB_REPO } from '../../download/api'
|
||||
import '../styles/landing.css'
|
||||
import { themeToCssVars } from '../../../clickgui/themeVars'
|
||||
import { useActiveTheme } from '../../../themes/useActiveTheme'
|
||||
import { DOWNLOAD_URL, GITHUB_REPO } from '../../../download/api'
|
||||
import '../../styles/landing.css'
|
||||
|
||||
const KEYS = ['1', '2', '3', '4', '5'] as const
|
||||
|
||||
export function FaqDownload() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { theme } = useActiveTheme()
|
||||
const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) }))
|
||||
|
||||
return (
|
||||
<section aria-labelledby="faq-title" className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
|
||||
<section aria-labelledby="faq-title" style={themeToCssVars(theme)} className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('faq.eyebrow')}</span>
|
||||
<h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2>
|
||||
<div className="flex flex-col divide-y divide-white/10 border-y border-white/10">
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<details key={it.q} className="lv-faq-item group py-3">
|
||||
<details key={it.q} className="lv-faq-item lv-glass">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-base font-semibold text-snow">
|
||||
{it.q}
|
||||
<span aria-hidden="true" className="lv-faq-marker" />
|
||||
|
|
@ -26,7 +29,7 @@ export function FaqDownload() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lv-download-band flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
|
||||
<div className="lv-download-band lv-glass flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
|
||||
<h3 className="text-[1.75rem] leading-tight sm:text-[2.1rem]">{t('faq.downloadTitle')}</h3>
|
||||
<p className="max-w-[42ch] text-lg text-frost">{t('faq.downloadLead')}</p>
|
||||
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { themeToCssVars } from '../../clickgui/themeVars'
|
||||
import { parseArgb } from '../../themes/types'
|
||||
import { useActiveTheme } from '../../themes/useActiveTheme'
|
||||
import { VoxelScene } from '../effects/VoxelScene'
|
||||
import '../styles/landing.css'
|
||||
import { Button } from '../../../../shared/ui/Button'
|
||||
import { themeToCssVars } from '../../../clickgui/themeVars'
|
||||
import { parseArgb } from '../../../themes/types'
|
||||
import { useActiveTheme } from '../../../themes/useActiveTheme'
|
||||
import { VoxelScene } from '../../effects/VoxelScene'
|
||||
import '../../styles/landing.css'
|
||||
|
||||
const STEP = 8
|
||||
/** Fallback playfield size, used before the box is measured (and under jsdom). */
|
||||
|
|
@ -26,6 +26,31 @@ const START: Record<WidgetId, Pos> = {
|
|||
|
||||
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
|
||||
|
||||
/** Pull every widget back inside the measured field (mount, resize, reset). */
|
||||
function fitToField(
|
||||
state: Record<WidgetId, Pos>,
|
||||
field: HTMLDivElement | null,
|
||||
widgets: Partial<Record<WidgetId, HTMLElement | null>>,
|
||||
): Record<WidgetId, Pos> {
|
||||
const w = field?.clientWidth || FIELD.w
|
||||
const h = field?.clientHeight || FIELD.h
|
||||
const next = { ...state }
|
||||
let changed = false
|
||||
for (const id of Object.keys(next) as WidgetId[]) {
|
||||
const el = widgets[id]
|
||||
const maxX = Math.max(0, w - (el?.offsetWidth || WIDGET.w))
|
||||
const maxY = Math.max(0, h - (el?.offsetHeight || WIDGET.h))
|
||||
const x = clamp(next[id].x, maxX)
|
||||
const y = clamp(next[id].y, maxY)
|
||||
if (x !== next[id].x || y !== next[id].y) {
|
||||
next[id] = { x, y }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : state
|
||||
}
|
||||
|
||||
|
||||
/** Enabled modules as they would appear in the mod's ModuleList panel. */
|
||||
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
|
||||
|
||||
|
|
@ -114,6 +139,7 @@ export function HudPlayground() {
|
|||
const { theme } = useActiveTheme()
|
||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const widgetRefs = useRef<Partial<Record<WidgetId, HTMLElement | null>>>({})
|
||||
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null)
|
||||
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
|
||||
|
||||
|
|
@ -124,6 +150,16 @@ export function HudPlayground() {
|
|||
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const fit = () => setPos((prev) => fitToField(prev, fieldRef.current, widgetRefs.current))
|
||||
fit()
|
||||
const field = fieldRef.current
|
||||
if (!field || typeof ResizeObserver === 'undefined') return
|
||||
const ro = new ResizeObserver(fit)
|
||||
ro.observe(field)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => {
|
||||
const { maxX, maxY } = bounds(el)
|
||||
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } }))
|
||||
|
|
@ -169,7 +205,7 @@ export function HudPlayground() {
|
|||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span>
|
||||
<h2 id="hud-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('hud.title')}</h2>
|
||||
<p className="max-w-[42ch] text-lg text-frost">{t('hud.lead')}</p>
|
||||
<Button variant="quiet" className="mt-2" onClick={() => setPos(START)}>
|
||||
<Button variant="quiet" className="mt-2" onClick={() => setPos(fitToField(START, fieldRef.current, widgetRefs.current))}>
|
||||
{t('hud.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -185,6 +221,9 @@ export function HudPlayground() {
|
|||
{widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
ref={(el) => {
|
||||
widgetRefs.current[w.id] = el
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={w.label}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { CATEGORIES } from '../../clickgui/parts/modules'
|
||||
import '../styles/landing.css'
|
||||
import { CATEGORIES } from '../../../clickgui/parts/modules'
|
||||
import '../../styles/landing.css'
|
||||
|
||||
/** Each column scrolls at its own pace; the list is duplicated so the loop is seamless. */
|
||||
const SPEED_MS = [42, 30, 50, 36]
|
||||
|
||||
export function ModuleWall() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { t: tc } = useTranslation('clickgui')
|
||||
|
||||
return (
|
||||
<section aria-labelledby="wall-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-14">
|
||||
|
|
@ -23,18 +22,14 @@ export function ModuleWall() {
|
|||
<h3 className="font-code text-xs uppercase tracking-[0.18em] text-ice">{cat.name}</h3>
|
||||
<div className="lv-wall-viewport relative h-[300px] overflow-hidden">
|
||||
<ul className="lv-wall-track flex flex-col gap-1.5" style={{ ['--dur' as string]: `${SPEED_MS[i % SPEED_MS.length]}s` }}>
|
||||
{[...cat.modules, ...cat.modules].map((m, idx) => {
|
||||
const desc = cat.id === 'visuals' ? tc(`desc.${m.name}`) : undefined
|
||||
return (
|
||||
{[...cat.modules, ...cat.modules].map((m, idx) => (
|
||||
<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>
|
||||
|
|
@ -1,25 +1,24 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { api } from '../../../shared/api/client'
|
||||
import '../styles/landing.css'
|
||||
|
||||
type ShowcaseItem = { id: string; name: string; author: string; downloads: number }
|
||||
import { browse } from '../../../showcase/api'
|
||||
import type { Listing } from '../../../showcase/types'
|
||||
import '../../styles/landing.css'
|
||||
|
||||
/** Placeholder cards so the section never collapses while loading or when the API is down. */
|
||||
const EXAMPLES: ShowcaseItem[] = [
|
||||
{ id: 'e1', name: 'Aura / Trails', author: 'example', downloads: 128 },
|
||||
{ id: 'e2', name: 'Clean PvP', author: 'example', downloads: 96 },
|
||||
{ id: 'e3', name: 'Stream Safe', author: 'example', downloads: 74 },
|
||||
const EXAMPLES: Listing[] = [
|
||||
{ id: 'e1', title: 'Aura / Trails', config_name: 'aura', description: '', copies_count: 128, published_at: '', author: { nick: 'example', avatar_url: null } },
|
||||
{ id: 'e2', title: 'Clean PvP', config_name: 'pvp', description: '', copies_count: 96, published_at: '', author: { nick: 'example', avatar_url: null } },
|
||||
{ id: 'e3', title: 'Stream Safe', config_name: 'stream', description: '', copies_count: 74, published_at: '', author: { nick: 'example', avatar_url: null } },
|
||||
]
|
||||
|
||||
export function ShowcaseTeaser() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['showcase', 'popular'],
|
||||
queryFn: () => api.request<ShowcaseItem[]>('/showcase?sort=popular'),
|
||||
queryKey: ['showcase', 'popular', 0, null],
|
||||
queryFn: () => browse('popular', 0),
|
||||
})
|
||||
const real = !isError && Array.isArray(data) ? data.slice(0, 3) : []
|
||||
const real = !isError && data ? data.items.slice(0, 3) : []
|
||||
const cards = real.length >= 3 ? real : EXAMPLES
|
||||
const isExample = real.length < 3
|
||||
|
||||
|
|
@ -35,11 +34,11 @@ export function ShowcaseTeaser() {
|
|||
{cards.map((c) => (
|
||||
<li key={c.id} className="lv-config-card flex flex-col gap-3 rounded-panel border border-white/10 bg-black/30 p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-display text-lg font-bold text-snow">{c.name}</span>
|
||||
<span className="font-display text-lg font-bold text-snow">{c.title}</span>
|
||||
{isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null}
|
||||
</div>
|
||||
<span className="text-sm text-frost">{t('showcase.by', { author: c.author })}</span>
|
||||
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.downloads })}</span>
|
||||
<span className="text-sm text-frost">{t('showcase.by', { author: c.author?.nick ?? t('showcase.example') })}</span>
|
||||
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.copies_count })}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import { MotionConfig, motion } from 'motion/react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ButtonLink } from '../../../shared/ui/Button'
|
||||
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
|
||||
import { DOWNLOAD_URL } from '../../download/api'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { parseArgb } from '../../themes/types'
|
||||
import { useActiveTheme } from '../../themes/useActiveTheme'
|
||||
import { ParticleField } from '../effects/ParticleField'
|
||||
import { Tilt } from '../effects/Tilt'
|
||||
import { useInView } from '../effects/useInView'
|
||||
import { VoxelScene } from '../effects/VoxelScene'
|
||||
import '../styles/landing.css'
|
||||
import { ButtonLink } from '../../../../shared/ui/Button'
|
||||
import { ClickGuiWindow } from '../../../clickgui/ClickGuiWindow'
|
||||
import { DOWNLOAD_URL } from '../../../download/api'
|
||||
import { PRESETS } from '../../../themes/presets.generated'
|
||||
import { parseArgb } from '../../../themes/types'
|
||||
import { useActiveTheme } from '../../../themes/useActiveTheme'
|
||||
import { ParticleField } from '../../effects/ParticleField'
|
||||
import { useInView } from '../../effects/useInView'
|
||||
import { VoxelScene } from '../../effects/VoxelScene'
|
||||
import '../../styles/landing.css'
|
||||
|
||||
const rise = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 24 },
|
||||
|
|
@ -45,13 +44,13 @@ export function Hero() {
|
|||
<section
|
||||
ref={sectionRef}
|
||||
aria-labelledby="hero-title"
|
||||
className="lv-bleed relative isolate flex min-h-[calc(100svh-3.75rem)] items-center overflow-hidden"
|
||||
className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh items-center overflow-hidden pt-(--topbar-h)"
|
||||
>
|
||||
<VoxelScene accent={accent} />
|
||||
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
|
||||
<ParticleField color={accent} />
|
||||
|
||||
<div className="relative mx-auto grid w-full max-w-[1200px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.12fr)] lg:gap-8 lg:py-16">
|
||||
<div className="relative mx-auto grid w-full max-w-[1120px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:py-16">
|
||||
<div className="flex min-w-0 flex-col items-start gap-6">
|
||||
<motion.span
|
||||
{...rise(0)}
|
||||
|
|
@ -94,9 +93,7 @@ export function Hero() {
|
|||
transition={{ duration: 0.9, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="flex min-w-0 flex-col items-center gap-4"
|
||||
>
|
||||
<Tilt className="w-full max-w-[660px]">
|
||||
<ClickGuiWindow paused={!inView} />
|
||||
</Tilt>
|
||||
<p className="flex items-center gap-2 text-sm text-frost">
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hero.tryIt')}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { motion } from 'motion/react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ButtonLink } from '../../../shared/ui/Button'
|
||||
import { themeToCssVars } from '../../clickgui/themeVars'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { argbToCss, type ThemeColors } from '../../themes/types'
|
||||
import { useActiveTheme } from '../../themes/useActiveTheme'
|
||||
import { useInView } from '../effects/useInView'
|
||||
import '../styles/landing.css'
|
||||
import { ButtonLink } from '../../../../shared/ui/Button'
|
||||
import { themeToCssVars } from '../../../clickgui/themeVars'
|
||||
import { PRESETS } from '../../../themes/presets.generated'
|
||||
import { argbToCss, type ThemeColors } from '../../../themes/types'
|
||||
import { useActiveTheme } from '../../../themes/useActiveTheme'
|
||||
import { useInView } from '../../effects/useInView'
|
||||
import '../../styles/landing.css'
|
||||
|
||||
const PALETTE_KEYS: (keyof ThemeColors)[] = [
|
||||
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',
|
||||
|
|
@ -174,12 +174,61 @@
|
|||
.lv-config-card { transition: transform 220ms ease, border-color 220ms ease; }
|
||||
.lv-config-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--color-ice) 40%, transparent); }
|
||||
|
||||
.lv-download-band {
|
||||
.lv-glass {
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--page-accent-glow, #6cf) 22%, transparent), transparent 60%),
|
||||
linear-gradient(180deg, #0e1430, #070a18);
|
||||
border: 1px solid color-mix(in srgb, var(--color-ice) 22%, transparent);
|
||||
linear-gradient(var(--gui-window-angle, 180deg),
|
||||
color-mix(in srgb, var(--gui-window-from, #12191b) 72%, transparent),
|
||||
color-mix(in srgb, var(--gui-window-to, #12191b) 72%, transparent)) padding-box,
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent, #6cf) 55%, transparent),
|
||||
var(--gui-stroke-from, #2a3a3f) 35%, var(--gui-stroke-to, #2a3a3f) 70%,
|
||||
color-mix(in srgb, var(--gui-accent, #6cf) 30%, transparent)) border-box;
|
||||
box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 26px -10px var(--gui-accent-glow, transparent);
|
||||
backdrop-filter: blur(12px) saturate(1.25);
|
||||
}
|
||||
.lv-download-band { position: relative; overflow: hidden; }
|
||||
.lv-download-band::before {
|
||||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--gui-accent, #6cf) 26%, transparent), transparent 60%);
|
||||
}
|
||||
.lv-download-band > * { position: relative; }
|
||||
.lv-faq-item { border-radius: 10px; padding: 0.85rem 1rem; transition: box-shadow 200ms ease; }
|
||||
.lv-faq-item:hover, .lv-faq-item[open] { box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 30px -6px var(--gui-accent-glow, transparent); }
|
||||
|
||||
/* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
|
||||
.lv-lower { position: relative; isolation: isolate; }
|
||||
.lv-lower-bg {
|
||||
position: absolute; inset: 0 calc(50% - 50vw); z-index: -1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(60% 30% at 15% 12%, color-mix(in srgb, var(--page-accent-glow, #6cf) 30%, transparent), transparent 70%),
|
||||
radial-gradient(55% 28% at 88% 45%, color-mix(in srgb, var(--color-ember) 14%, transparent), transparent 70%),
|
||||
radial-gradient(60% 30% at 20% 82%, color-mix(in srgb, var(--page-accent-glow, #6cf) 24%, transparent), transparent 70%),
|
||||
linear-gradient(180deg, #0d1416, #0a0f1f 40%, #120d24 75%, #0d1416);
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent, #000 6%, #000 94%, transparent);
|
||||
mask-image: linear-gradient(180deg, transparent, #000 6%, #000 94%, transparent);
|
||||
}
|
||||
.lv-lower-bg::before {
|
||||
content: ""; position: absolute; inset: 0; opacity: 0.5;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
-webkit-mask-image: radial-gradient(80% 60% at 50% 50%, #000, transparent);
|
||||
mask-image: radial-gradient(80% 60% at 50% 50%, #000, transparent);
|
||||
}
|
||||
.lv-lower-bg::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background-image:
|
||||
radial-gradient(1.5px 1.5px at 12% 20%, var(--color-ice), transparent),
|
||||
radial-gradient(1.5px 1.5px at 78% 34%, var(--color-ice), transparent),
|
||||
radial-gradient(1px 1px at 44% 58%, #fff, transparent),
|
||||
radial-gradient(1.5px 1.5px at 90% 76%, var(--color-ice), transparent),
|
||||
radial-gradient(1px 1px at 26% 90%, #fff, transparent);
|
||||
background-size: 100% 900px; opacity: 0.55;
|
||||
animation: lv-motes 40s linear infinite alternate;
|
||||
}
|
||||
@keyframes lv-motes { from { transform: translateY(0); } to { transform: translateY(-40px); } }
|
||||
@media (prefers-reduced-motion: reduce) { .lv-lower-bg::after { animation: none; } }
|
||||
|
||||
.lv-faq-item > summary::-webkit-details-marker { display: none; }
|
||||
.lv-faq-marker { width: 10px; height: 10px; border-right: 2px solid var(--color-ice); border-bottom: 2px solid var(--color-ice); transform: rotate(45deg); transition: transform 200ms ease; }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
|
|||
import { expect, test, vi } from 'vitest'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { CommandHero } from '../CommandHero'
|
||||
import { Hero } from '../sections/Hero'
|
||||
import { Hero } from '../sections/hero/Hero'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { createMemoryRouter, RouterProvider } from 'react-router'
|
|||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { mockFetch } from '../../../test/fetch'
|
||||
import { ParticleField } from '../effects/ParticleField'
|
||||
import { HudPlayground } from '../sections/HudPlayground'
|
||||
import { ModuleWall } from '../sections/ModuleWall'
|
||||
import { ShowcaseTeaser } from '../sections/ShowcaseTeaser'
|
||||
import { HudPlayground } from '../sections/explore/HudPlayground'
|
||||
import { ModuleWall } from '../sections/explore/ModuleWall'
|
||||
import { ShowcaseTeaser } from '../sections/explore/ShowcaseTeaser'
|
||||
|
||||
const reduceMotion = () =>
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
|
|
@ -44,7 +44,7 @@ test('HudWidget stays inside the playfield bounds', () => {
|
|||
test('ModuleWall lists all four category headings', () => {
|
||||
reduceMotion()
|
||||
withQuery(<ModuleWall />)
|
||||
for (const name of ['Combat', 'Visuals', 'Player', 'Misc']) {
|
||||
for (const name of ['Combat', 'Player', 'Visuals', 'Other']) {
|
||||
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
13
frontend/src/features/profile/api.ts
Normal file
13
frontend/src/features/profile/api.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
|
||||
export type PublicProfile = {
|
||||
id: string
|
||||
display_nick: string
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
badges: string[]
|
||||
}
|
||||
|
||||
export function getProfile(id: string): Promise<PublicProfile> {
|
||||
return api.request(`/users/${id}`)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiError } from '../../../shared/api/client'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { ListingList } from '../../showcase/components/ListingList'
|
||||
import { getProfile } from '../api'
|
||||
|
||||
export function PublicProfileView({ id }: { id: string }) {
|
||||
const { t, i18n } = useTranslation('profile')
|
||||
const { data, error, isPending } = useQuery({ queryKey: ['profile', id], queryFn: () => getProfile(id), retry: false })
|
||||
|
||||
if (isPending) return <p className="text-frost">…</p>
|
||||
if (error || !data) {
|
||||
const missing = error instanceof ApiError && error.status === 404
|
||||
return <Notice tone="error">{missing ? t('notFound') : t('error')}</Notice>
|
||||
}
|
||||
|
||||
const joined = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'long' }).format(new Date(data.created_at))
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex items-center gap-5 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6">
|
||||
<div className="flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
|
||||
{data.avatar_url ? (
|
||||
<img src={data.avatar_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span aria-hidden="true">{data.display_nick.slice(0, 1).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate font-display text-2xl">{data.display_nick}</h1>
|
||||
<p className="mt-1 text-sm text-frost">{t('memberSince', { date: joined })}</p>
|
||||
{data.badges.includes('early') ? (
|
||||
<span className="mt-2 inline-block rounded-full border border-ice/40 px-3 py-0.5 font-code text-xs text-ice">
|
||||
{t('badgeEarly')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-xl">{t('configs')}</h2>
|
||||
<ListingList author={id} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/features/profile/i18n/en.ts
Normal file
7
frontend/src/features/profile/i18n/en.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const profileEn = {
|
||||
memberSince: 'On LoVisual since {{date}}',
|
||||
badgeEarly: 'One of the first 1000',
|
||||
configs: "Player's configs",
|
||||
notFound: 'No such player.',
|
||||
error: 'Could not load the profile. Try again later.',
|
||||
}
|
||||
7
frontend/src/features/profile/i18n/ru.ts
Normal file
7
frontend/src/features/profile/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const profileRu = {
|
||||
memberSince: 'В LoVisual с {{date}}',
|
||||
badgeEarly: 'Один из первых 1000',
|
||||
configs: 'Конфиги игрока',
|
||||
notFound: 'Такого игрока нет.',
|
||||
error: 'Не удалось загрузить профиль. Попробуй позже.',
|
||||
}
|
||||
27
frontend/src/features/profile/tests/profile.test.tsx
Normal file
27
frontend/src/features/profile/tests/profile.test.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { PublicProfileView } from '../components/PublicProfileView'
|
||||
|
||||
const profile = { id: 'u1', display_nick: 'loki', avatar_url: null, created_at: '2026-01-05T10:00:00Z', badges: ['early'] }
|
||||
|
||||
test('shows nick, early badge and the player configs', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /users/u1': () => json(profile),
|
||||
'GET /showcase': () => json({ items: [], page: 0, has_more: false }),
|
||||
})
|
||||
renderApp([{ path: '/', element: <PublicProfileView id="u1" /> }])
|
||||
expect(await screen.findByText('loki')).toBeInTheDocument()
|
||||
expect(screen.getByText('Один из первых 1000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('unknown player shows not found', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /users/nope': () => json({ error: 'no such account' }, 404),
|
||||
})
|
||||
renderApp([{ path: '/', element: <PublicProfileView id="nope" /> }])
|
||||
expect(await screen.findByText('Такого игрока нет.')).toBeInTheDocument()
|
||||
})
|
||||
16
frontend/src/features/showcase/api.ts
Normal file
16
frontend/src/features/showcase/api.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
import type { ListingDetail, ShowcasePage } from './types'
|
||||
|
||||
export function browse(sort: 'new' | 'popular', page: number, author?: string): Promise<ShowcasePage> {
|
||||
const params = new URLSearchParams({ sort, page: String(page) })
|
||||
if (author) params.set('author', author)
|
||||
return api.request(`/showcase?${params.toString()}`)
|
||||
}
|
||||
|
||||
export function getListing(id: string): Promise<ListingDetail> {
|
||||
return api.request(`/showcase/${id}`)
|
||||
}
|
||||
|
||||
export function copyToSlot(id: string, slot: number): Promise<unknown> {
|
||||
return api.request(`/showcase/${id}/copy`, { method: 'POST', json: { slot } })
|
||||
}
|
||||
75
frontend/src/features/showcase/components/CopyToSlot.tsx
Normal file
75
frontend/src/features/showcase/components/CopyToSlot.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { ApiError } from '../../../shared/api/client'
|
||||
import { useSession } from '../../auth/session'
|
||||
import { listSlots } from '../../configs/api'
|
||||
import { copyToSlot } from '../api'
|
||||
|
||||
const SLOTS = [1, 2, 3, 4] as const
|
||||
|
||||
export function CopyToSlot({ listingId }: { listingId: string }) {
|
||||
const { t } = useTranslation('showcase')
|
||||
const { status } = useSession()
|
||||
const location = useLocation()
|
||||
const client = useQueryClient()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedSlot, setCopiedSlot] = useState<number | null>(null)
|
||||
|
||||
const { data: slots } = useQuery({ queryKey: ['configs'], queryFn: listSlots, enabled: status === 'authenticated' })
|
||||
const occupied = new Set((slots ?? []).map((s) => s.slot))
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (slot: number) => copyToSlot(listingId, slot),
|
||||
onSuccess: (_data, slot) => {
|
||||
setError(null)
|
||||
setCopiedSlot(slot)
|
||||
client.invalidateQueries({ queryKey: ['configs'] })
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof ApiError && err.status === 409 ? t('copy.conflict') : t('page.error'))
|
||||
},
|
||||
})
|
||||
|
||||
if (status !== 'authenticated') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-frost">{t('copy.loginPrompt')}</p>
|
||||
<Link to="/login" state={{ from: location.pathname }} className="text-ice hover:underline">
|
||||
{t('copy.login')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-lg text-snow">{t('copy.heading')}</h2>
|
||||
{copiedSlot ? (
|
||||
<Notice tone="info">
|
||||
{t('copy.success', { slot: copiedSlot })}{' '}
|
||||
<Link to="/configs" className="text-ice hover:underline">
|
||||
{t('copy.openConfigs')}
|
||||
</Link>
|
||||
</Notice>
|
||||
) : null}
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<div role="group" aria-label={t('copy.heading')} className="inline-flex overflow-hidden rounded-md border border-shoal">
|
||||
{SLOTS.map((slot) => (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
disabled={occupied.has(slot) || mutation.isPending}
|
||||
title={occupied.has(slot) ? t('copy.occupied') : undefined}
|
||||
onClick={() => mutation.mutate(slot)}
|
||||
className="border-r border-shoal px-4 py-2 text-sm font-semibold text-snow last:border-r-0 hover:bg-ice/10 disabled:cursor-not-allowed disabled:text-frost/50 disabled:hover:bg-transparent"
|
||||
>
|
||||
{t('copy.slot', { slot })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
frontend/src/features/showcase/components/ListingDetail.tsx
Normal file
53
frontend/src/features/showcase/components/ListingDetail.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { getListing } from '../api'
|
||||
import { copiesText } from '../copies'
|
||||
import { CopyToSlot } from './CopyToSlot'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
|
||||
}
|
||||
|
||||
export function ListingDetail({ id }: { id: string }) {
|
||||
const { t, i18n } = useTranslation('showcase')
|
||||
const { data, isError, isPending } = useQuery({ queryKey: ['listing', id], queryFn: () => getListing(id) })
|
||||
|
||||
if (isPending) return null
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Notice tone="error">{t('detail.notFound')}</Notice>
|
||||
<Link to="/showcase" className="text-ice hover:underline">
|
||||
{t('detail.back')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-4 rounded-(--radius-panel) border border-shoal bg-slate/60 px-5 py-6 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]">
|
||||
<Link to="/showcase" className="text-sm text-ice hover:underline">
|
||||
{t('detail.back')}
|
||||
</Link>
|
||||
<h1 className="font-display text-3xl text-snow">{data.title}</h1>
|
||||
<p className="font-code text-sm text-ice">{data.config_name}</p>
|
||||
<p className="whitespace-pre-line text-frost">{data.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-frost">
|
||||
{data.author && data.author_id ? (
|
||||
<Link to={`/u/${data.author_id}`} className="text-ice underline-offset-4 hover:underline">
|
||||
{data.author.nick}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{data.author ? data.author.nick : t('item.authorHidden')}</span>
|
||||
)}
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatDate(data.published_at)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{copiesText(t, i18n.language, data.copies_count)}</span>
|
||||
</div>
|
||||
<CopyToSlot listingId={data.id} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
29
frontend/src/features/showcase/components/ListingItem.tsx
Normal file
29
frontend/src/features/showcase/components/ListingItem.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { copiesText } from '../copies'
|
||||
import type { Listing } from '../types'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date(iso))
|
||||
}
|
||||
|
||||
export function ListingItem({ listing }: { listing: Listing }) {
|
||||
const { t, i18n } = useTranslation('showcase')
|
||||
const copies = copiesText(t, i18n.language, listing.copies_count)
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-2 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-4 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)] transition-colors hover:border-ice/60">
|
||||
<Link to={`/showcase/${listing.id}`} className="flex flex-col gap-2">
|
||||
<h3 className="font-display text-lg text-snow">{listing.title}</h3>
|
||||
<p className="font-code text-xs text-ice">{listing.config_name}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-frost">
|
||||
<span>{listing.author ? listing.author.nick : t('item.authorHidden')}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{copies}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatDate(listing.published_at)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
81
frontend/src/features/showcase/components/ListingList.tsx
Normal file
81
frontend/src/features/showcase/components/ListingList.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { browse } from '../api'
|
||||
import { ListingItem } from './ListingItem'
|
||||
|
||||
function readSort(params: URLSearchParams): 'new' | 'popular' {
|
||||
return params.get('sort') === 'popular' ? 'popular' : 'new'
|
||||
}
|
||||
|
||||
function readPage(params: URLSearchParams): number {
|
||||
const n = Number(params.get('page'))
|
||||
return Number.isInteger(n) && n >= 0 ? n : 0
|
||||
}
|
||||
|
||||
export function ListingList({ author }: { author?: string } = {}) {
|
||||
const { t } = useTranslation('showcase')
|
||||
const [params, setParams] = useSearchParams()
|
||||
const sort = readSort(params)
|
||||
const page = readPage(params)
|
||||
|
||||
const { data, isError, isPending } = useQuery({
|
||||
queryKey: ['showcase', sort, page, author ?? null],
|
||||
queryFn: () => browse(sort, page, author),
|
||||
})
|
||||
|
||||
function setSort(next: 'new' | 'popular') {
|
||||
setParams((p) => {
|
||||
const q = new URLSearchParams(p)
|
||||
q.set('sort', next)
|
||||
q.set('page', '0')
|
||||
return q
|
||||
})
|
||||
}
|
||||
|
||||
function setPage(next: number) {
|
||||
setParams((p) => {
|
||||
const q = new URLSearchParams(p)
|
||||
q.set('page', String(next))
|
||||
return q
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-2" role="group" aria-label={t('page.title')}>
|
||||
<Button variant={sort === 'new' ? 'primary' : 'quiet'} aria-pressed={sort === 'new'} onClick={() => setSort('new')}>
|
||||
{t('page.sortNew')}
|
||||
</Button>
|
||||
<Button variant={sort === 'popular' ? 'primary' : 'quiet'} aria-pressed={sort === 'popular'} onClick={() => setSort('popular')}>
|
||||
{t('page.sortPopular')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError ? <Notice tone="error">{t('page.error')}</Notice> : null}
|
||||
|
||||
{!isError && !isPending && data && data.items.length === 0 ? <Notice tone="info">{t('page.empty')}</Notice> : null}
|
||||
|
||||
{data && data.items.length > 0 ? (
|
||||
<ul className="grid grid-cols-1 gap-4 min-[720px]:grid-cols-2">
|
||||
{data.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<ListingItem listing={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="quiet" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
{t('page.back')}
|
||||
</Button>
|
||||
<Button variant="quiet" disabled={!data?.has_more} onClick={() => setPage(page + 1)}>
|
||||
{t('page.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
frontend/src/features/showcase/copies.ts
Normal file
17
frontend/src/features/showcase/copies.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
|
||||
const RU_PLURAL = new Intl.PluralRules('ru')
|
||||
|
||||
/** Russian pluralization for "Скопировали N раз/раза/раз", exported for a focused unit test. */
|
||||
export function copiesLabel(count: number): string {
|
||||
const form = RU_PLURAL.select(count)
|
||||
const word = form === 'one' ? 'раз' : form === 'few' ? 'раза' : 'раз'
|
||||
return `Скопировали ${count} ${word}`
|
||||
}
|
||||
|
||||
export function copiesText(t: TFunction<'showcase'>, lang: string, count: number): string {
|
||||
if (lang === 'ru') return copiesLabel(count)
|
||||
const form = RU_PLURAL.select(count)
|
||||
const key = form === 'one' ? 'item.copiesOne' : form === 'few' ? 'item.copiesFew' : 'item.copiesMany'
|
||||
return t(key, { count })
|
||||
}
|
||||
32
frontend/src/features/showcase/i18n/en.ts
Normal file
32
frontend/src/features/showcase/i18n/en.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const showcaseEn = {
|
||||
page: {
|
||||
title: 'Showcase',
|
||||
lead: 'Configs shared by other players. Like one? Copy it into your own slot.',
|
||||
sortNew: 'Newest',
|
||||
sortPopular: 'Popular',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
empty: 'The showcase is empty for now. Publish your config on the My configs page.',
|
||||
error: 'The showcase is unavailable. Try refreshing the page later.',
|
||||
},
|
||||
item: {
|
||||
authorHidden: 'Author hidden',
|
||||
copiesOne: 'Copied {{count}} time',
|
||||
copiesFew: 'Copied {{count}} times',
|
||||
copiesMany: 'Copied {{count}} times',
|
||||
},
|
||||
detail: {
|
||||
back: 'Back to showcase',
|
||||
notFound: 'This config does not exist or is hidden.',
|
||||
},
|
||||
copy: {
|
||||
heading: 'Copy to my slot',
|
||||
slot: 'Slot {{slot}}',
|
||||
occupied: 'Occupied',
|
||||
loginPrompt: 'Log in to copy this config to your account',
|
||||
login: 'Log in',
|
||||
success: 'Copied to slot {{slot}}',
|
||||
openConfigs: 'Open my configs',
|
||||
conflict: 'That slot is already taken. Pick a free one or clear a slot in the mod.',
|
||||
},
|
||||
} as const
|
||||
32
frontend/src/features/showcase/i18n/ru.ts
Normal file
32
frontend/src/features/showcase/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const showcaseRu = {
|
||||
page: {
|
||||
title: 'Витрина',
|
||||
lead: 'Конфиги, которыми поделились игроки. Понравился, скопируй в свой слот.',
|
||||
sortNew: 'Новые',
|
||||
sortPopular: 'Популярные',
|
||||
back: 'Назад',
|
||||
next: 'Дальше',
|
||||
empty: 'На витрине пока пусто. Опубликуй свой конфиг на странице «Мои конфиги».',
|
||||
error: 'Витрина недоступна. Попробуй обновить страницу позже.',
|
||||
},
|
||||
item: {
|
||||
authorHidden: 'Автор скрыт',
|
||||
copiesOne: 'Скопировали {{count}} раз',
|
||||
copiesFew: 'Скопировали {{count}} раза',
|
||||
copiesMany: 'Скопировали {{count}} раз',
|
||||
},
|
||||
detail: {
|
||||
back: 'Назад к витрине',
|
||||
notFound: 'Такого конфига нет или он скрыт.',
|
||||
},
|
||||
copy: {
|
||||
heading: 'Скопировать себе',
|
||||
slot: 'Слот {{slot}}',
|
||||
occupied: 'Занят',
|
||||
loginPrompt: 'Войди, чтобы скопировать конфиг к себе',
|
||||
login: 'Войти',
|
||||
success: 'Скопировано в слот {{slot}}',
|
||||
openConfigs: 'Открыть мои конфиги',
|
||||
conflict: 'Этот слот уже занят. Выбери свободный или освободи слот в моде.',
|
||||
},
|
||||
} as const
|
||||
87
frontend/src/features/showcase/tests/showcase.test.tsx
Normal file
87
frontend/src/features/showcase/tests/showcase.test.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { copiesLabel } from '../copies'
|
||||
import { ListingList } from '../components/ListingList'
|
||||
import { CopyToSlot } from '../components/CopyToSlot'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
const listing = {
|
||||
id: 'l1',
|
||||
title: 'Aura / Trails',
|
||||
description: 'Мой любимый набор',
|
||||
config_name: 'pvp',
|
||||
copies_count: 3,
|
||||
published_at: '2026-09-20T10:00:00Z',
|
||||
author: { nick: 'loki', avatar_url: null },
|
||||
}
|
||||
|
||||
test('russian plural for copies', () => {
|
||||
expect(copiesLabel(1)).toBe('Скопировали 1 раз')
|
||||
expect(copiesLabel(3)).toBe('Скопировали 3 раза')
|
||||
expect(copiesLabel(5)).toBe('Скопировали 5 раз')
|
||||
expect(copiesLabel(21)).toBe('Скопировали 21 раз')
|
||||
})
|
||||
|
||||
test('list renders items with author and plural', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /showcase': () => json({ items: [listing], page: 0, has_more: false }),
|
||||
})
|
||||
renderApp([{ path: '/', element: <ListingList /> }])
|
||||
expect(await screen.findByText('Aura / Trails')).toBeInTheDocument()
|
||||
expect(screen.getByText('loki')).toBeInTheDocument()
|
||||
expect(screen.getByText('Скопировали 3 раза')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('sort toggle updates the query and refetches', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetch = mockFetch({
|
||||
'POST /auth/refresh': () => json({}, 401),
|
||||
'GET /showcase': () => json({ items: [listing], page: 0, has_more: false }),
|
||||
})
|
||||
renderApp([{ path: '/', element: <ListingList /> }], '/?sort=new')
|
||||
await screen.findByText('Aura / Trails')
|
||||
await user.click(screen.getByRole('button', { name: 'Популярные' }))
|
||||
expect(fetch.mock.calls.some(([u]) => String(u).includes('sort=popular'))).toBe(true)
|
||||
})
|
||||
|
||||
test('anonymous copy shows the login prompt', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
|
||||
expect(await screen.findByText('Войди, чтобы скопировать конфиг к себе')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('authenticated copy disables occupied slots and posts the slot', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetch = mockFetch({
|
||||
'POST /auth/refresh': () => json({ access_token: 't' }),
|
||||
'GET /me': () => json({ id: 'u1', email: 'a@b.c', nick: 'loki', avatar_url: null }),
|
||||
'GET /configs': () => json([{ slot: 1, name: 'x', updated_at: '2026-09-20T10:00:00Z', share_code: 'A', published: false }]),
|
||||
'POST /showcase/l1/copy': () => json({}, 201),
|
||||
})
|
||||
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
|
||||
const slot1 = await screen.findByRole('button', { name: 'Слот 1' })
|
||||
await waitFor(() => expect(slot1).toBeDisabled())
|
||||
const slot2 = screen.getByRole('button', { name: 'Слот 2' })
|
||||
await user.click(slot2)
|
||||
expect(await screen.findByText('Скопировано в слот 2')).toBeInTheDocument()
|
||||
const body = fetch.mock.calls.find(([u]) => String(u).endsWith('/copy'))?.[1]?.body
|
||||
expect(JSON.parse(String(body))).toEqual({ slot: 2 })
|
||||
})
|
||||
|
||||
test('409 shows the conflict message', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({ access_token: 't' }),
|
||||
'GET /me': () => json({ id: 'u1', email: 'a@b.c', nick: 'loki', avatar_url: null }),
|
||||
'GET /configs': () => json([]),
|
||||
'POST /showcase/l1/copy': () => json({ error: 'target slot is not empty' }, 409),
|
||||
})
|
||||
renderApp([{ path: '/', element: <CopyToSlot listingId="l1" /> }])
|
||||
const slot1 = await screen.findByRole('button', { name: 'Слот 1' })
|
||||
await user.click(slot1)
|
||||
const panel = slot1.closest('div') as HTMLElement
|
||||
expect(await within(panel.parentElement as HTMLElement).findByText(/Этот слот уже занят/)).toBeInTheDocument()
|
||||
})
|
||||
16
frontend/src/features/showcase/types.ts
Normal file
16
frontend/src/features/showcase/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export type Author = { nick: string; avatar_url: string | null }
|
||||
|
||||
export type Listing = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
config_name: string
|
||||
copies_count: number
|
||||
published_at: string
|
||||
author: Author | null
|
||||
author_id?: string | null
|
||||
}
|
||||
|
||||
export type ListingDetail = Listing & { data: unknown }
|
||||
|
||||
export type ShowcasePage = { items: Listing[]; page: number; has_more: boolean }
|
||||
|
|
@ -4,24 +4,32 @@ import { themeToCssVars } from '../../clickgui/themeVars'
|
|||
import { VoxelScene } from '../../landing/effects/VoxelScene'
|
||||
import { parseArgb, type ThemeEntry } from '../types'
|
||||
|
||||
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */
|
||||
export function EditorPreview({ draft }: { draft: ThemeEntry }) {
|
||||
type Props = {
|
||||
draft: ThemeEntry
|
||||
/** A theme card clicked inside the preview's Settings screen: load it into the draft, not the site. */
|
||||
onPickPreset: (id: string) => void
|
||||
}
|
||||
|
||||
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene, sized to fit its column. */
|
||||
export function EditorPreview({ draft, onPickPreset }: Props) {
|
||||
const vars = useMemo(() => themeToCssVars(draft), [draft])
|
||||
const { r, g, b } = parseArgb(draft.theme.accent)
|
||||
|
||||
return (
|
||||
<div className="lv-editor-preview relative min-h-[420px] overflow-hidden rounded-panel border border-white/10">
|
||||
<div className="lv-editor-preview relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-panel border border-white/10">
|
||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} className="absolute inset-0" />
|
||||
<div className="relative flex h-full flex-col items-center justify-center gap-6 p-6" style={vars}>
|
||||
<div className="flex w-full justify-center gap-4">
|
||||
<span className="lv-hud-tile" style={{ background: 'var(--gui-card-gradient)' }}>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4" style={vars}>
|
||||
<div className="flex shrink-0 justify-center gap-3">
|
||||
<span className="lv-hud-tile lv-hud-tile-card" style={{ background: 'var(--gui-card-gradient)' }}>
|
||||
<b>FPS</b> 240
|
||||
</span>
|
||||
<span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}>
|
||||
<b>XYZ</b> 12 · 64 · -8
|
||||
</span>
|
||||
</div>
|
||||
<ClickGuiWindow theme={draft} demo={false} className="w-full max-w-[520px]" />
|
||||
<div className="lv-editor-stage">
|
||||
<ClickGuiWindow theme={draft} demo={false} onPickTheme={onPickPreset} className="lv-editor-gui" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { PRESETS } from '../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
|
||||
import { EditorPreview } from './EditorPreview'
|
||||
import { ColorField } from './ColorField'
|
||||
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec'
|
||||
import { GradientField } from './GradientField'
|
||||
import { useThemeHistory } from './useThemeHistory'
|
||||
import { ColorField } from './fields/ColorField'
|
||||
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './model/codec'
|
||||
import { GradientField } from './fields/GradientField'
|
||||
import { useThemeHistory } from './model/useThemeHistory'
|
||||
|
||||
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */
|
||||
const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
|
||||
|
||||
export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
||||
/** `intro` (page title + lead) sits on top of the controls column so the whole editor fits one screen. */
|
||||
export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: ReactNode }) {
|
||||
const { t } = useTranslation('themes')
|
||||
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
|
@ -61,6 +62,11 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
flash(t('editor.shared'))
|
||||
}
|
||||
|
||||
const pickPreset = (id: string) => {
|
||||
const preset = PRESETS.find((p) => p.id === id)
|
||||
if (preset) reset(preset)
|
||||
}
|
||||
|
||||
const onFile = (file?: File) => {
|
||||
if (!file) return
|
||||
file.text().then((text) => {
|
||||
|
|
@ -71,10 +77,13 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<EditorPreview draft={draft} />
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 py-6 [grid-template-areas:'intro'_'preview'_'controls'] lg:h-[calc(100svh-var(--topbar-h))] lg:min-h-[560px] lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-x-8 lg:gap-y-4 lg:py-4 lg:[grid-template-areas:'preview_intro'_'preview_controls']">
|
||||
{intro && <div className="[grid-area:intro]">{intro}</div>}
|
||||
<div className="flex min-h-0 flex-col [grid-area:preview]">
|
||||
<EditorPreview draft={draft} onPickPreset={pickPreset} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-h-0 flex-col gap-4 [grid-area:controls]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
|
||||
{t('editor.undo')}
|
||||
|
|
@ -88,6 +97,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
|
||||
</div>
|
||||
|
||||
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-6 lg:-mr-3 lg:overflow-y-auto lg:pr-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-frost">{t('editor.nameLabel')}</span>
|
||||
<input
|
||||
|
|
@ -115,7 +125,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
{COLOR_SLOTS.map((slot) => (
|
||||
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
|
||||
))}
|
||||
|
|
@ -136,7 +146,9 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-white/10 pt-4 sm:grid-cols-4 lg:grid-cols-2">
|
||||
<Button onClick={copy}>{t('editor.copy')}</Button>
|
||||
<Button variant="quiet" onClick={download}>
|
||||
{t('editor.download')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
|
||||
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
|
||||
|
||||
/* On desktop the editor is one viewport tall: the stage is a size container and the ClickGui
|
||||
(narrow layout, 264:330) takes the largest width that still fits its height. */
|
||||
.lv-editor-gui { margin-inline: auto; }
|
||||
@media (min-width: 1024px) {
|
||||
.lv-editor-stage { flex: 1; min-height: 0; container-type: size; display: grid; place-items: center; }
|
||||
.lv-editor-gui { width: min(100cqw, 100cqh * 264 / 330); }
|
||||
}
|
||||
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: rgb(255 255 255 / 0.18) transparent; }
|
||||
|
||||
.lv-hud-tile {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
|
|
@ -14,6 +23,7 @@
|
|||
box-shadow: 0 6px 20px -12px #000;
|
||||
}
|
||||
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
|
||||
.lv-hud-tile-card b { color: var(--gui-text, #fff); }
|
||||
|
||||
.lv-preset-chip {
|
||||
padding: 4px 12px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useId, useState } from 'react'
|
||||
import { parseArgb } from '../types'
|
||||
import { argbToCss, parseArgb } from '../../types'
|
||||
|
||||
type Props = { label: string; value: string; onChange: (hex: string) => void }
|
||||
|
||||
|
|
@ -28,12 +28,10 @@ export function ColorField({ label, value, onChange }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<label htmlFor={id} className="lv-color-field flex items-center gap-2">
|
||||
<span
|
||||
className="lv-alpha-check size-8 shrink-0 rounded-md border border-white/15"
|
||||
style={{ backgroundImage: `linear-gradient(45deg, ${value}, ${value})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2">
|
||||
<span className="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15" aria-hidden="true">
|
||||
<span className="block size-full" style={{ background: argbToCss(value) }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-xs text-frost">{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { GradientSpec } from '../types'
|
||||
import type { GradientSpec } from '../../types'
|
||||
import { ColorField } from './ColorField'
|
||||
|
||||
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
|
||||
|
|
@ -9,7 +9,7 @@ export function GradientField({ title, value, onChange }: Props) {
|
|||
const { t } = useTranslation('themes')
|
||||
|
||||
return (
|
||||
<div className="lv-gradient-field flex flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
|
||||
<div className="lv-gradient-field flex min-w-0 flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-snow">{title}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-frost">
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { PRESETS } from '../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
|
||||
|
||||
// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as
|
||||
// `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { ThemeEntry } from '../types'
|
||||
import type { ThemeEntry } from '../../types'
|
||||
|
||||
const LIMIT = 50
|
||||
/** Changes closer than this share one undo step, so a slider drag is a single revert. */
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec'
|
||||
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../model/codec'
|
||||
|
||||
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
|
||||
const PROFILE_KEYS = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { ActiveThemeProvider, useActiveTheme } from '../../useActiveTheme'
|
||||
import { ThemeEditor } from '../ThemeEditor'
|
||||
|
||||
test('editor shows the initial preset name and one toggle per gradient slot', () => {
|
||||
|
|
@ -16,3 +18,24 @@ test('picking a preset chip loads it into the editor', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: next.name }))
|
||||
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
function SiteTheme() {
|
||||
return <output aria-label="site theme">{useActiveTheme().theme.id}</output>
|
||||
}
|
||||
|
||||
test('a theme card inside the preview loads that preset into the draft, not the site', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ActiveThemeProvider applyToPage={false}>
|
||||
<SiteTheme />
|
||||
<ThemeEditor initial={PRESETS[0]} />
|
||||
</ActiveThemeProvider>,
|
||||
)
|
||||
const gui = screen.getByTestId('clickgui-root')
|
||||
await user.click(within(gui).getByRole('tab', { name: 'Settings' }))
|
||||
await user.click(within(gui).getByRole('option', { name: 'Тема Nitro' }))
|
||||
expect(screen.getByDisplayValue('Nitro')).toBeInTheDocument()
|
||||
expect(within(gui).getByRole('option', { name: 'Тема Nitro' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('status', { name: 'site theme' })).toHaveTextContent(PRESETS[0].id)
|
||||
expect(window.localStorage.getItem('lv_theme')).toBeNull()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -44,6 +44,6 @@ export const themesEn = {
|
|||
reset: 'Reset',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
importError: 'Could not read the theme — the file is malformed.',
|
||||
importError: 'Could not read the theme: the file is malformed.',
|
||||
},
|
||||
} satisfies Translation<typeof themesRu>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@ export const themesRu = {
|
|||
reset: 'Сбросить',
|
||||
undo: 'Отменить',
|
||||
redo: 'Вернуть',
|
||||
importError: 'Не удалось прочитать тему — файл испорчен.',
|
||||
importError: 'Не удалось прочитать тему: файл испорчен.',
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@
|
|||
|
||||
@layer base {
|
||||
html {
|
||||
/* Height reserved for the floating top bar (TopBar): its top margin plus the bar itself. */
|
||||
--topbar-h: 4.25rem;
|
||||
scroll-padding-top: calc(var(--topbar-h) + 1rem);
|
||||
color-scheme: dark;
|
||||
transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
|
||||
}
|
||||
|
|
@ -64,3 +67,7 @@
|
|||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Konami easter egg: the whole page cycles through the hue wheel for a few seconds. */
|
||||
html.lv-konami { animation: lv-konami-hue 1.4s linear infinite; }
|
||||
@keyframes lv-konami-hue { to { filter: hue-rotate(360deg); } }
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { StrictMode } from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { initI18n } from './app/i18n'
|
||||
import { printConsoleGreeting } from './shared/easter/consoleGreeting'
|
||||
import App from './app/App.tsx'
|
||||
|
||||
initI18n()
|
||||
printConsoleGreeting(navigator.language)
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
11
frontend/src/pages/app/AccountPage.tsx
Normal file
11
frontend/src/pages/app/AccountPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { DeviceList } from '../../features/account/components/DeviceList'
|
||||
import { ProfileCard } from '../../features/account/components/ProfileCard'
|
||||
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<section className="grid grid-cols-1 gap-6 py-12 min-[900px]:grid-cols-[320px_1fr]">
|
||||
<ProfileCard />
|
||||
<DeviceList />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
30
frontend/src/pages/app/ConfigsPage.tsx
Normal file
30
frontend/src/pages/app/ConfigsPage.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticleField } from '../../features/landing/effects/ParticleField'
|
||||
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
|
||||
import { SlotGrid } from '../../features/configs/components/SlotGrid'
|
||||
|
||||
const ACCENT = 'rgb(92, 200, 231)'
|
||||
|
||||
export default function ConfigsPage() {
|
||||
const { t } = useTranslation('configs')
|
||||
return (
|
||||
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
|
||||
<VoxelScene accent={ACCENT} />
|
||||
</div>
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
|
||||
<ParticleField color={ACCENT} className="opacity-50" />
|
||||
|
||||
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
|
||||
<p className="mt-2 max-w-[60ch] text-frost">
|
||||
{t('page.leadBefore')} <code className="font-code text-ice">%config save <слот></code>
|
||||
{t('page.leadAfter')}
|
||||
</p>
|
||||
</div>
|
||||
<SlotGrid />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
35
frontend/src/pages/app/LinkPage.tsx
Normal file
35
frontend/src/pages/app/LinkPage.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
|
||||
|
||||
const STEPS = ['one', 'two', 'three'] as const
|
||||
|
||||
export default function LinkPage() {
|
||||
const { t } = useTranslation('deviceLink')
|
||||
return (
|
||||
<section className="flex flex-col gap-8 py-12">
|
||||
<div>
|
||||
<h1 className="text-3xl">{t('page.title')}</h1>
|
||||
<p className="mt-2 max-w-sm text-frost">{t('page.lead')}</p>
|
||||
</div>
|
||||
|
||||
<ol className="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
{STEPS.map((step, i) => (
|
||||
<li
|
||||
key={step}
|
||||
className="flex flex-1 items-start gap-3 rounded-(--radius-panel) border border-shoal bg-slate/60 px-4 py-3"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-6 w-6 flex-none items-center justify-center rounded-full bg-ice font-code text-sm font-bold text-abyss"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<p className="text-sm text-frost">{t(`steps.${step}`)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<LinkDeviceForm />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +1,26 @@
|
|||
import { CommandHero } from '../../features/landing/CommandHero'
|
||||
import { HowItWorks } from '../../features/landing/HowItWorks'
|
||||
import { FaqDownload } from '../../features/landing/sections/FaqDownload'
|
||||
import { Hero } from '../../features/landing/sections/Hero'
|
||||
import { HudPlayground } from '../../features/landing/sections/HudPlayground'
|
||||
import { ModuleWall } from '../../features/landing/sections/ModuleWall'
|
||||
import { ShowcaseTeaser } from '../../features/landing/sections/ShowcaseTeaser'
|
||||
import { ThemeStrip } from '../../features/landing/sections/ThemeStrip'
|
||||
import { FaqDownload } from '../../features/landing/sections/explore/FaqDownload'
|
||||
import { Hero } from '../../features/landing/sections/hero/Hero'
|
||||
import { HudPlayground } from '../../features/landing/sections/explore/HudPlayground'
|
||||
import { ModuleWall } from '../../features/landing/sections/explore/ModuleWall'
|
||||
import { ShowcaseTeaser } from '../../features/landing/sections/explore/ShowcaseTeaser'
|
||||
import { ThemeStrip } from '../../features/landing/sections/hero/ThemeStrip'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<ThemeStrip />
|
||||
<div className="lv-lower">
|
||||
<div aria-hidden="true" className="lv-lower-bg" />
|
||||
<HudPlayground />
|
||||
<CommandHero />
|
||||
<HowItWorks />
|
||||
<ModuleWall />
|
||||
<ShowcaseTeaser />
|
||||
<FaqDownload />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { LoginForm } from '../../features/auth/forms/LoginForm'
|
||||
import { AuthSplit } from './auth/AuthSplit'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">{t('login.title')}</h1>
|
||||
<AuthSplit title={t('login.title')}>
|
||||
<LoginForm />
|
||||
</section>
|
||||
</AuthSplit>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<section className="flex flex-col gap-4 py-16">
|
||||
<pre aria-hidden="true" className="w-fit max-w-full overflow-x-auto rounded-panel border border-white/10 bg-black/30 p-4 font-code text-xs leading-tight text-ice">
|
||||
{NOT_FOUND_ART}
|
||||
{t('notFound.console')}
|
||||
</pre>
|
||||
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1>
|
||||
<p className="text-frost">
|
||||
{t('notFound.body')}{' '}
|
||||
|
|
|
|||
11
frontend/src/pages/public/ProfilePage.tsx
Normal file
11
frontend/src/pages/public/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useParams } from 'react-router'
|
||||
import { PublicProfileView } from '../../features/profile/components/PublicProfileView'
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { id = '' } = useParams()
|
||||
return (
|
||||
<section className="py-12">
|
||||
<PublicProfileView id={id} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
|
||||
import { AuthSplit } from './auth/AuthSplit'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">{t('register.title')}</h1>
|
||||
<p className="max-w-sm text-frost">{t('register.subtitle')}</p>
|
||||
<AuthSplit title={t('register.title')}>
|
||||
<p className="mb-4 max-w-sm text-frost">{t('register.subtitle')}</p>
|
||||
<RegisterForm />
|
||||
</section>
|
||||
</AuthSplit>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
36
frontend/src/pages/public/auth/AuthSplit.tsx
Normal file
36
frontend/src/pages/public/auth/AuthSplit.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClickGuiWindow } from '../../../features/clickgui/ClickGuiWindow'
|
||||
import { ParticleField } from '../../../features/landing/effects/ParticleField'
|
||||
import { VoxelScene } from '../../../features/landing/effects/VoxelScene'
|
||||
import { useActiveTheme } from '../../../features/themes/useActiveTheme'
|
||||
import { parseArgb } from '../../../features/themes/types'
|
||||
|
||||
/** Two-pane auth layout: a ClickGui-styled glass form panel, and a decorative scene (hidden < 900px). */
|
||||
export function AuthSplit({ title, children }: { title: string; children: ReactNode }) {
|
||||
const { t } = useTranslation('auth')
|
||||
const { theme } = useActiveTheme()
|
||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||
const particleColor = `rgb(${r}, ${g}, ${b})`
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-var(--topbar-h)-4rem)] items-center justify-center py-10 min-[900px]:justify-between min-[900px]:gap-12">
|
||||
<div className="w-full max-w-md rounded-(--radius-panel) border border-shoal bg-slate/80 shadow-[0_0_60px_-20px_var(--page-accent-glow,transparent)] backdrop-blur">
|
||||
<div className="flex items-center gap-2 border-b border-shoal px-5 py-4">
|
||||
<span aria-hidden="true" className="h-2.5 w-2.5 rounded-full bg-ice" />
|
||||
<h1 className="font-display text-xl">{title}</h1>
|
||||
</div>
|
||||
<div className="px-5 py-6">{children}</div>
|
||||
</div>
|
||||
|
||||
<div className="relative hidden min-[900px]:flex min-[900px]:h-[520px] min-[900px]:w-[440px] min-[900px]:shrink-0 min-[900px]:items-end min-[900px]:overflow-hidden min-[900px]:rounded-(--radius-panel) min-[900px]:border min-[900px]:border-shoal">
|
||||
<VoxelScene accent={particleColor} />
|
||||
<ParticleField color={particleColor} className="mix-blend-screen" />
|
||||
<div className="relative z-10 w-full p-5">
|
||||
<ClickGuiWindow demo className="w-full max-w-[300px] drop-shadow-2xl" />
|
||||
<p className="mt-4 max-w-[300px] font-display text-sm text-snow drop-shadow">{t('splitPitch')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
frontend/src/pages/showcase/ListingPage.tsx
Normal file
21
frontend/src/pages/showcase/ListingPage.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useParams } from 'react-router'
|
||||
import { ParticleField } from '../../features/landing/effects/ParticleField'
|
||||
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
|
||||
import { ListingDetail } from '../../features/showcase/components/ListingDetail'
|
||||
|
||||
const ACCENT = 'rgb(92, 200, 231)'
|
||||
|
||||
export default function ListingPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
return (
|
||||
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
|
||||
<VoxelScene accent={ACCENT} />
|
||||
</div>
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
|
||||
<ParticleField color={ACCENT} className="opacity-50" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[720px] px-4 pt-12 sm:px-6">{id ? <ListingDetail id={id} /> : null}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
11
frontend/src/pages/showcase/ListingRoute.tsx
Normal file
11
frontend/src/pages/showcase/ListingRoute.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const ListingPage = lazy(() => import('./ListingPage'))
|
||||
|
||||
export default function ListingRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
|
||||
<ListingPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
27
frontend/src/pages/showcase/ShowcasePage.tsx
Normal file
27
frontend/src/pages/showcase/ShowcasePage.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticleField } from '../../features/landing/effects/ParticleField'
|
||||
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
|
||||
import { ListingList } from '../../features/showcase/components/ListingList'
|
||||
|
||||
const ACCENT = 'rgb(92, 200, 231)'
|
||||
|
||||
export default function ShowcasePage() {
|
||||
const { t } = useTranslation('showcase')
|
||||
return (
|
||||
<section className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh flex-col gap-8 overflow-hidden pt-(--topbar-h) pb-16">
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 opacity-40">
|
||||
<VoxelScene accent={ACCENT} />
|
||||
</div>
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-abyss/70" />
|
||||
<ParticleField color={ACCENT} className="opacity-50" />
|
||||
|
||||
<div className="relative mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-4 pt-12 sm:px-6">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl text-snow">{t('page.title')}</h1>
|
||||
<p className="mt-2 max-w-[60ch] text-frost">{t('page.lead')}</p>
|
||||
</div>
|
||||
<ListingList />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/showcase/ShowcaseRoute.tsx
Normal file
12
frontend/src/pages/showcase/ShowcaseRoute.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
|
||||
// Loaded on demand: the showcase pages pull in extra query/routing glue not needed on the landing.
|
||||
const ShowcasePage = lazy(() => import('./ShowcasePage'))
|
||||
|
||||
export default function ShowcaseRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
|
||||
<ShowcasePage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
|
||||
import { fromShareHash } from '../../features/themes/editor/codec'
|
||||
import { fromShareHash } from '../../features/themes/editor/model/codec'
|
||||
import { PRESETS } from '../../features/themes/presets.generated'
|
||||
import '../../features/themes/editor/editor.css'
|
||||
|
||||
|
|
@ -19,12 +19,14 @@ export default function ThemesPage() {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-6 py-12">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('editor.title')}</h1>
|
||||
<p className="text-frost">{t('editor.lead')}</p>
|
||||
<ThemeEditor
|
||||
initial={initial}
|
||||
intro={
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="font-display text-3xl font-black uppercase text-snow">{t('editor.title')}</h1>
|
||||
<p className="text-sm text-frost">{t('editor.lead')}</p>
|
||||
</header>
|
||||
<ThemeEditor initial={initial} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue