chore(history): squash 67 commit(s) from 2026-09-25
- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints - feat(frontend): app shell, routing and landing page with the chat-command hero - feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion - docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans - feat(accounts): internal gRPC AuthenticateDevice guarded by internal key - feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch - feat(accounts): GET /me profile endpoint - feat(gateway): scaffold crate with config validation and health check - feat(gateway): reverse proxy to accounts and configs services - feat(gateway): resolve identity once from access JWT or device token via gRPC - feat(gateway): per-route and global rate limits with Retry-After - feat(gateway): CORS for the site origin; docs for gateway and internal contract - feat(configs): scaffold service with schema, config validation and health check - feat(configs): four config slots per account with list, get and save - feat(configs): permanent share codes with regenerate and public load-by-code - feat(accounts): GetPublicProfiles gRPC for showcase author info - style(accounts,common): apply rustfmt to existing sources - feat(configs): public showcase with publish, browse, detail and copy-to-slot - feat(backend): public profile endpoint and showcase author filter - fix(gateway): silence clippy collapsible-if and needless-ref warnings - docs(backend): configs-service implemented; Подсистема 1 backend complete - feat(mod): add Optimize module skeleton with OptimizeState holder - feat(mod): gate glass blur behind Optimize no_glass knob - feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob - feat(mod): trim procedural sky noise behind lite_sky knob - feat(mod): drop fade gradients and digit rolls behind lean_hud knob - docs(todo): mark Optimize module phase 9.2 complete - refactor(mod): drop dead Renderer2D compatibility shims - refactor(mod): prune unreachable Renderer2D overload towers - refactor(mod): remove unused Renderer2D overloads and imports - docs(todo): mark Renderer2D giant-splitting done (2179 to 1597) - refactor(mod): extract shader id constants from LoVisualRenderPipelines - docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines) - refactor(mod): move Renderer2D instance state into base class - refactor(mod): extract Renderer2DRounded drawing family - refactor(mod): extract Renderer2DPath connector and chamfer family - refactor(mod): extract Renderer2DShapes circle line and texture primitives - refactor(mod): extract Renderer2DGlass and Renderer2DItem families - refactor(mod): prune Renderer2D imports after facade split - docs(todo): record Renderer2D facade inheritance split (1597 to 475) - docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot - feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer) - refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants) - refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec - feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution - feat(mod): flat fallback for no-glass optimize mode and persist global HUD config - feat(mod): default HUD bg effects to Auto so the global surface style drives widgets - feat(mod): add global cycle-style hotkey with surface style notification - feat(mod): add surface style swatch strip under the global style picker - feat(gateway): reject ambiguous paths and answer .env probes with a honeypot - fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth - feat(frontend): ClickGui theme pipeline generated from the mod, live site theming - feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip - docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9 - feat(gateway): answer /coffee with a 418 teapot - feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer - feat(frontend): one-click download from GitHub releases, changelog page, release CI - feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links - fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping) - style(frontend): apply ClickGui glass effect to landing HUD playground widgets - fix(frontend): prevent color field row overflow in theme editor grid - fix(frontend): never attach stale bearer token to /auth/* requests - fix(configs): unpublish/publish can no longer bypass moderation - refactor(accounts): shrink auth/handlers.rs under the 250-line cap - fix(accounts): tolerate concurrent refresh without killing every session - fix(gateway): minor hardening from the backend review - feat(configs): IDDQD easter egg config
This commit is contained in:
parent
72bc4c7148
commit
7f4b532f99
257 changed files with 13085 additions and 6582 deletions
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# GitHub repository (owner/name) whose releases carry the `lovisual.jar` asset.
|
||||
VITE_GITHUB_REPO=loki5512344/LoVisual-
|
||||
|
|
@ -34,12 +34,19 @@ frontend/src/
|
|||
client.ts # общий fetch-wrapper: base URL gateway, JWT в заголовке,
|
||||
# обработка 401 (refresh) и 429 (rate-limit) в одном месте
|
||||
types.ts # общие DTO, если совпадают на нескольких фичах
|
||||
i18n/ # LanguageSwitch, common.ru.ts / common.en.ts (typed dictionaries)
|
||||
pages/ # роуты верхнего уровня, тонкие — просто собирают
|
||||
# фичи в layout, никакой бизнес-логики
|
||||
App.tsx
|
||||
app/ # App.tsx, routes.tsx, i18n.ts (initI18n), i18next.d.ts (typed keys)
|
||||
main.tsx
|
||||
```
|
||||
|
||||
Каждая фича, у которой есть строки для пользователя, также владеет своей
|
||||
`i18n/` подпапкой (`ru.ts`, `en.ts`) — одно пространство имён i18next на
|
||||
фичу (`auth`, `landing`, ...), русские словари как источник (`as const`),
|
||||
английские типизированы через `satisfies Translation<typeof ru>` из
|
||||
`shared/i18n/common.ru.ts`, так что расхождение ключей — ошибка компиляции.
|
||||
|
||||
Правило раздела shared/feature: если код используется ровно в одной фиче —
|
||||
он живёт в этой фиче, а не в `shared/`. Переносить в `shared/` только когда
|
||||
появился второй потребитель — не заранее "на всякий случай" (YAGNI).
|
||||
|
|
@ -94,3 +101,9 @@ frontend/src/
|
|||
```
|
||||
|
||||
Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9).
|
||||
|
||||
## Единственное допустимое ребро shared → feature
|
||||
`shared/layout/` (шапка и каркас приложения) импортирует `useSession` из
|
||||
`features/auth/session`: шапке нужно знать, вошёл ли пользователь. Это
|
||||
единственный случай, когда `shared/` зависит от фичи; остальной `shared/`
|
||||
от фич не зависит.
|
||||
|
|
|
|||
167
frontend/PLAN.md
167
frontend/PLAN.md
|
|
@ -10,7 +10,9 @@
|
|||
|
||||
## Global Constraints
|
||||
|
||||
- All user-facing text in Russian, sentence case, active voice. Code identifiers/comments in English.
|
||||
- **Bilingual (ru default, en).** From Task 5A on, no user-facing string is hard-coded: every string goes through i18next (`useTranslation`), with Russian as the source dictionary and English kept key-for-key identical (enforced by types). Sentence case, active voice in both. Code identifiers/comments in English.
|
||||
- **The mod is free.** No pricing, subscriptions, keys or HWID anywhere on the site. «Скачать» is the primary action everywhere.
|
||||
- **Visually rich, not minimal** (owner's explicit requirement after reviewing the first landing): every public page has a real visual centerpiece that shows the product (live ClickGui/HUD replicas, particle/bloom effects matching the mod, procedural scenes), orchestrated motion via `motion` (respecting `prefers-reduced-motion`), and depth. Text-and-boxes pages are rejected.
|
||||
- ≤250 lines per file, ≤4 files per folder (subfolders don't count). Tests live in a `tests/` subfolder of the feature/shared folder they cover (colocating `X.test.tsx` next to `X.tsx` would halve every folder's capacity under the 4-file rule) — Task 2 updates `ARCHITECTURE.md` accordingly.
|
||||
- No `any`. DTOs typed from the backend contracts (`backend/PLAN.md`, `backend/gateway/PLAN.md` Tasks 3–6, `backend/configs-service/PLAN.md`).
|
||||
- No barrel `index.ts` files. No business logic in `pages/`.
|
||||
|
|
@ -37,7 +39,14 @@
|
|||
| `ice` | `#5CC8E7` | the one accent: primary actions, focus, share codes |
|
||||
| `ember` | `#E8835A` | destructive actions and errors only |
|
||||
|
||||
**Type:** Comfortaa (the mod's own main-menu face; rounded, Cyrillic) for headings only; Inter Variable for everything else; Iosevka only where the text is literally typed in-game — share codes, the `%config load` command, device user codes. Scale (≈1.25): 14 / 16 / 20 / 25 / 31 / 39 px, hero 61 px. Body line length ≤ 70ch.
|
||||
**Type (revised 2026-09-25 — Cyrillic-first, commit "Cyrillic-first fonts"):** Unbounded Variable (wide geometric display face with full Cyrillic; headings, big numbers, logo) — `font-display`; Onest Variable (designed for Cyrillic; all body/UI text) — `font-sans`; JetBrains Mono Variable (has Cyrillic, unlike Iosevka; share codes, commands, chat lines, device codes) — `font-code`. Scale (≈1.25): 14 / 16 / 20 / 25 / 31 / 39 px, hero clamp(2.5rem, 6vw, 5rem). Body line length ≤ 70ch.
|
||||
|
||||
**Visual direction (revised 2026-09-25):** the site looks like the mod running. Signature pieces, all built in code (no stock images; no AI-image API keys are available):
|
||||
- **ClickGui replica** — an HTML/CSS reproduction of the in-game ClickGui window (category column Combat / Visuals / Player / Misc, module cards with toggles and setting sliders, the theme's gradients and accent bloom), themable at runtime from the mod's real presets, with subtle 3D tilt following the pointer.
|
||||
- **HUD replicas** — TargetHUD, Keybinds, Potions, Watermark, Armor widgets drawn like the mod's HUD, draggable with the pointer.
|
||||
- **Particle field** — a `<canvas>` of soft glowing particles (the mod's AmbientParticles/bloom look) tinted by the active theme accent; pauses when off-screen or with reduced motion.
|
||||
- **Voxel scene** — a procedural blocky landscape/sky (canvas or CSS 3D) as the backdrop the HUD sits on, so no copyrighted screenshots are needed. Real in-game screenshots from the owner can replace it later.
|
||||
- Gradients are allowed where they reproduce the mod's own theme gradients; decorative gradient washes are still out.
|
||||
|
||||
**Layout:** Left-aligned, max width 1120px, 24px gutters (16px on phones). Top bar: logo left, three nav links (Витрина, Мои конфиги, Привязать игру), account button right. Panels (slate fill, 1px shoal border, 10px radius — the ClickGui window shape) are used only for things that *are* panels in the game: config slots, showcase entries, forms. Everything else sits directly on the page.
|
||||
|
||||
|
|
@ -1040,6 +1049,146 @@ git commit -m "feat(frontend): app shell, routing and landing page with the chat
|
|||
|
||||
---
|
||||
|
||||
### Task 5A: i18n foundation (ru/en) and migrating existing strings
|
||||
|
||||
**Why now:** every later task writes strings; converting after the fact doubles the work.
|
||||
|
||||
**Files:**
|
||||
- Move: `src/App.tsx`, `src/routes.tsx` → `src/app/App.tsx`, `src/app/routes.tsx` (src/ top level becomes `main.tsx`, `index.css` + `app/`), update imports.
|
||||
- Create: `src/app/i18n.ts` (init), `src/app/i18next.d.ts` (typed keys), `src/shared/i18n/{common.ru.ts,common.en.ts,LanguageSwitch.tsx}`, `src/shared/i18n/tests/i18n.test.tsx`
|
||||
- Create per feature: `src/features/<feature>/i18n/{ru.ts,en.ts}` for `auth` and `landing` now; later tasks add their own.
|
||||
- Modify: every component with hard-coded text (shared/ui ShareCode, shared/api/errors.ts, shared/layout, features/auth forms + validation, features/landing, pages/public/*), `src/test/setup.ts` (init i18n with `lng: 'ru'` synchronously), `index.html` (`lang` set at runtime).
|
||||
|
||||
**Interfaces:**
|
||||
- One i18next namespace per feature (`common`, `auth`, `landing`, later `account`, `configs`, `showcase`, `download`, `themes`, `profile`, `link`). Russian dictionaries are `as const` objects; English ones are typed `satisfies Translation<typeof ru>` where `type Translation<T> = { [K in keyof T]: T[K] extends string ? string : Translation<T[K]> }` (exported from `shared/i18n/common.ru.ts`) — a missing or extra key in `en` is a compile error.
|
||||
- `app/i18n.ts`: `initI18n(lng?: 'ru' | 'en'): i18n` — `i18next.use(LanguageDetector).use(initReactI18next).init({ resources: { ru: {...namespaces}, en: {...} }, fallbackLng: 'ru', supportedLngs: ['ru','en'], ns: [...], defaultNS: 'common', interpolation: { escapeValue: false }, detection: { order: ['localStorage','navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' } })`; on `languageChanged` set `document.documentElement.lang`.
|
||||
- `app/i18next.d.ts`: `declare module 'i18next' { interface CustomTypeOptions { defaultNS: 'common'; resources: { common: typeof commonRu; auth: typeof authRu; landing: typeof landingRu /* extended by later tasks */ } } }` so `t('auth:login.submit')` is type-checked.
|
||||
- `LanguageSwitch`: two-state toggle «RU / EN» in the TopBar (and footer), `aria-pressed` on the active one, calls `i18n.changeLanguage`.
|
||||
- `describeError(err, overrides)` takes the `t` function: `describeError(t, err, overrides)`; messages move to `common` namespace (`errors.rateLimited` with `{{seconds}}` interpolation, etc.).
|
||||
- Plurals via i18next plural keys (`_one/_few/_many` for ru, `_one/_other` for en).
|
||||
|
||||
- [ ] **Step 1: Failing tests (`shared/i18n/tests/i18n.test.tsx`)**
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next'
|
||||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { LanguageSwitch } from '../LanguageSwitch'
|
||||
|
||||
function Probe() {
|
||||
const { t } = useTranslation('auth')
|
||||
return <p>{t('login.submit')}</p>
|
||||
}
|
||||
|
||||
test('switching language re-renders strings and sets <html lang>', async () => {
|
||||
const user = userEvent.setup()
|
||||
const i18n = initI18n('ru')
|
||||
render(<I18nextProvider i18n={i18n}><Probe /><LanguageSwitch /></I18nextProvider>)
|
||||
expect(screen.getByText('Войти')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'EN' }))
|
||||
expect(screen.getByText('Log in')).toBeInTheDocument()
|
||||
expect(document.documentElement.lang).toBe('en')
|
||||
})
|
||||
|
||||
test('russian plurals', () => {
|
||||
const i18n = initI18n('ru')
|
||||
expect(i18n.t('common:time.days', { count: 1 })).toBe('1 день')
|
||||
expect(i18n.t('common:time.days', { count: 3 })).toBe('3 дня')
|
||||
expect(i18n.t('common:time.days', { count: 5 })).toBe('5 дней')
|
||||
})
|
||||
```
|
||||
Existing tests keep asserting Russian text — they must still pass unchanged after the migration (that's the regression check). Add one English smoke test for the landing hero headline.
|
||||
|
||||
- [ ] **Step 2: Implement; migrate every existing string; `grep -rnP '[А-Яа-яЁё]' src --include=*.tsx --include=*.ts | grep -v '/i18n/' | grep -v '/tests/'` must print nothing.**
|
||||
- [ ] **Step 3: test/build/lint; commit** — `feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch`
|
||||
|
||||
---
|
||||
|
||||
### Task 5B: Landing v2 — the product on screen
|
||||
|
||||
Replaces the Task 5 landing body (keep `CommandHero`'s chat line — it becomes one section). Everything is drawn in code.
|
||||
|
||||
**Files:** new feature folders (each ≤4 files + subfolders):
|
||||
- `src/features/clickgui/` — `ClickGuiWindow.tsx` (window chrome + category column + module list), `ModuleCard.tsx` (toggle, expandable settings: slider/checkbox/color dot), `themeVars.ts` (`themeToCssVars(entry: ThemeEntry): CSSProperties` — maps the mod's 12 colors + 5 gradients to CSS custom properties `--gui-window-bg` …), `demo.ts` (scripted demo timeline); `tests/`.
|
||||
- `src/features/themes/` — `presets.generated.ts` (from the mod, see Task 5D Step 1 — generate it in THIS task, 5D reuses it), `types.ts` (`ThemeEntry`, `GradientSpec`, `argbToCss(hex: string): string` for `#AARRGGBB`), `useActiveTheme.ts` (context: active preset id, `setTheme`, persisted in `localStorage` key `lv_theme`); `tests/`.
|
||||
- `src/features/hud/` — `HudWidget.tsx` (draggable wrapper: pointer events, clamped to parent, keyboard-movable with arrow keys when focused), `widgets/{TargetHud,Keybinds,Potions,Watermark}.tsx`; `tests/`.
|
||||
- `src/features/landing/` — `CommandHero.tsx` (existing chat line, now a section), `HowItWorks.tsx` (existing, restyled), `sections/{Hero,ThemeStrip,HudPlayground,ModuleWall,ShowcaseTeaser,FaqDownload}.tsx`, `effects/{ParticleField.tsx,VoxelScene.tsx,useInView.ts}`; `tests/`.
|
||||
|
||||
**Page composition (top to bottom):**
|
||||
1. **Hero** — full-viewport. Backdrop: `VoxelScene` (procedural blocky hills + dusk sky, parallax on scroll) under `ParticleField` (canvas, ~120 soft particles, accent-tinted, `requestAnimationFrame`, paused via `IntersectionObserver` and when `prefers-reduced-motion`). Left: headline (Unbounded, clamp size) «Визуалы, которые видно» / en «Visuals you can see», lead «Бесплатный легит-мод для Minecraft 26.2: 52 визуальных модуля, облачные конфиги и темы, которые ты собираешь сам.», primary «Скачать бесплатно» (direct release link, Task 5C `DOWNLOAD_URL`) + secondary «Создать аккаунт». Right: `ClickGuiWindow` in 3D (`perspective` + `rotateX/Y` from pointer, max 8°, spring via `motion`), running `demo.ts`: a fake cursor moves, toggles «Trails», opens its settings, drags a slider, then switches theme; loop every ~12s; pauses on hover (user takes over — cards are clickable).
|
||||
2. **ThemeStrip** — «Сотни оттенков. Твой — один.»: horizontal row of every preset from `presets.generated.ts` as swatch pills (window bg + accent + gradient); clicking one re-themes the hero ClickGui and the page accent live (CSS vars on `:root` via `useActiveTheme`) with a 400ms cross-fade; CTA «Собрать свою тему» → `/themes`.
|
||||
3. **HudPlayground** — `VoxelScene` crop with 4 draggable HUD widgets; caption «Перетащи — так же, как в игре.»; «Сбросить» button restores positions.
|
||||
4. **Cloud configs** — the existing `CommandHero` chat line + `HowItWorks` 2×2 slots, now animated in sequence when scrolled into view (`useInView`): `%config save 1` → slot fills with a pulse → code appears → second chat line `%config load 7KQ3M9XA` → «Конфиг загружен».
|
||||
5. **ModuleWall** — «88 модулей. 52 — про красоту.»: dense wall of module names (from a list in `landing/i18n` — names stay as in-game, untranslated) in 4 category columns; hovering a visuals module shows a one-line description tooltip; the wall slowly auto-scrolls vertically in each column at different speeds (CSS `@keyframes` translateY on duplicated lists, `animation-play-state: paused` on hover and under reduced motion).
|
||||
6. **ShowcaseTeaser** — top 3 popular configs from `GET /showcase?sort=popular` (TanStack Query); on error/empty shows 3 skeleton-styled example cards labelled «Пример» (never a broken section).
|
||||
7. **FaqDownload** — FAQ accordion (`<details>`; 5 questions: бесплатно ли, какие версии, нужен ли аккаунт, безопасно ли / легит, как перенести настройки) + final download band with version, Minecraft version and size (from Task 5C release data).
|
||||
8. **Footer** — logo, nav, GitHub link, `LanguageSwitch`, «Не связано с Mojang или Microsoft.»
|
||||
|
||||
**Interfaces:**
|
||||
- `themeToCssVars(entry)` output keys (used by ClickGui CSS): `--gui-window-bg, --gui-header, --gui-stroke, --gui-surface, --gui-surface-hover, --gui-card-on, --gui-card-off, --gui-text, --gui-text-muted, --gui-accent, --gui-accent-soft, --gui-stroke-soft`, plus `--gui-{window,header,surface,card,stroke}-gradient` as `linear-gradient(<angle>deg, start, end)` when enabled, else the flat color.
|
||||
- `demo.ts`: `type DemoStep = { at: number /*ms*/; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }`, `DEMO: DemoStep[]`, `useDemo(steps, { paused }): { cursor: {x,y}, state }`.
|
||||
- `HudWidget` props: `{ id: string; initial: { x: number; y: number }; children: ReactNode; label: string }` — `aria-label={label}`, `tabIndex=0`, arrows move 8px.
|
||||
|
||||
- [ ] **Step 1: Failing tests** — `themeToCssVars` maps a preset exactly (ARGB `#F012191B` → `rgba(18, 25, 27, 0.941)`); clicking a ThemeStrip swatch changes `--gui-accent` on the ClickGui root; `HudWidget` moves with ArrowRight by 8px and stays inside its parent bounds; `ParticleField` renders nothing animated (no rAF scheduled) under reduced motion (mock `matchMedia`); ModuleWall lists all four category headings; ShowcaseTeaser falls back to example cards on fetch error; hero download link points to the release URL.
|
||||
- [ ] **Step 2: Implement.** Performance budget: landing JS ≤ 220 kB gzip (check `bun run build` output), no layout shift from fonts (`font-display: swap` is the fontsource default; reserve hero height), 60fps particle field on a mid laptop (cap DPR at 2, ≤150 particles).
|
||||
- [ ] **Step 3: Visual check** with headless Firefox screenshots at 1440 and 375 (`firefox --headless --screenshot` — note it captures before long animations settle, so also verify the reduced-motion static state), both languages. Fix overflow, contrast, clipping.
|
||||
- [ ] **Step 4: commit** — `feat(frontend): landing v2 with live ClickGui, theme strip, HUD playground and module wall`
|
||||
|
||||
---
|
||||
|
||||
### Task 5C: Download (one click from GitHub Releases) + «Что нового»
|
||||
|
||||
**Decisions:** the download button is a plain link to `https://github.com/${VITE_GITHUB_REPO}/releases/latest/download/lovisual.jar` — GitHub redirects straight to the file, so one click downloads the latest build with no intermediate page. This requires every release to carry an asset named exactly `lovisual.jar` (Step 1 adds the CI that guarantees it). `VITE_GITHUB_REPO=loki5512344/LoVisual-` in `frontend/.env` (and `.env.example`). The repo is private today — until it is public, the link 404s for visitors; the page shows the changelog fallback message then.
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/release.yml` (repo root) — on tag `v*`: JDK 25 (match `mod/build.gradle` toolchain), `./gradlew build` in `mod/`, copy the remapped jar to `lovisual.jar`, create the release with both `lovisual-<version>.jar` and `lovisual.jar` via `softprops/action-gh-release@v2` (`generate_release_notes: true`).
|
||||
- Create: `src/features/download/{api.ts,DownloadButton.tsx,ReleaseNotes.tsx}`, `src/features/download/i18n/{ru,en}.ts`, `src/features/download/tests/download.test.tsx`, `src/pages/public/…` → pages/public is full (4 files) — create `src/pages/download/DownloadPage.tsx`.
|
||||
- Modify: `src/app/routes.tsx` (`/download`), TopBar (a «Скачать» primary button on the right, before login), Landing hero/FAQ band (use `DownloadButton`).
|
||||
- Add dependency: `react-markdown` (release notes are Markdown; react-markdown does not render raw HTML by default — keep it that way, no `rehype-raw`).
|
||||
|
||||
**Interfaces:**
|
||||
- `api.ts`: `DOWNLOAD_URL: string`; `type Release = { tag_name: string; name: string; published_at: string; body: string; html_url: string; assets: { name: string; size: number; download_count: number }[] }`; `fetchReleases(): Promise<Release[]>` → `GET https://api.github.com/repos/${repo}/releases?per_page=10` (unauthenticated, 60 req/h/IP is plenty; TanStack Query `staleTime: 10 min`).
|
||||
- `DownloadButton` props: `{ size?: 'md' | 'lg' }` — `<a href={DOWNLOAD_URL} download>` styled as primary button, label «Скачать бесплатно», sub-label with latest version + size when releases loaded («v0.1.1 · 4,2 МБ»).
|
||||
- `ReleaseNotes`: list of releases (version, date via `Intl.DateTimeFormat(i18n.language)`, Markdown body, «Все релизы на GitHub» link). Error/empty → «Список изменений появится, когда выйдет первый публичный релиз.»
|
||||
- `DownloadPage` (`/download`): big button, requirements block (Minecraft 26.2, Fabric Loader 0.19.4+, Fabric API; optional Sodium/Iris — values in the download dictionary, sourced from `mod/gradle.properties`), 3-step install guide (real sequence: скачать → положить в `mods` → запустить с профилем Fabric), then `ReleaseNotes`.
|
||||
|
||||
- [ ] **Step 1: CI workflow** (no test; validate with `actionlint` if available, otherwise careful review). Tagging/pushing is the owner's action — do not push tags.
|
||||
- [ ] **Step 2: Failing tests** — button href equals `https://github.com/loki5512344/LoVisual-/releases/latest/download/lovisual.jar` (with `vi.stubEnv('VITE_GITHUB_REPO', …)`); release notes render Markdown headings and do NOT render a raw `<script>`/`<img onerror>` from the body as HTML; fetch failure shows the fallback text.
|
||||
- [ ] **Step 3: Implement; routes; TopBar button.**
|
||||
- [ ] **Step 4: commit** — `feat(frontend): one-click download from GitHub releases, changelog page, release CI`
|
||||
|
||||
---
|
||||
|
||||
### Task 5D: Theme editor (`/themes`)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/scripts/extract-themes.ts` (bun script) + `package.json` script `"gen:themes": "bun scripts/extract-themes.ts"` — parses `mod/src/main/java/dev/loki/lovisual/features/theme/presets/ThemePresets{1,2,3}.java` (`new Theme(0x…, … 12 ints)` and `new ThemeEntry(id, name, builtin, THEME, new GradientSpec(bool, 0x…, 0x…, angle) ×5)`) into `src/features/themes/presets.generated.ts` (`export const PRESETS: ThemeEntry[]`, header comment "generated — do not edit"). If Task 5B already created it, reuse.
|
||||
- Create: `src/features/themes/editor/{ThemeEditor.tsx,ColorField.tsx,GradientField.tsx,codec.ts}`, `src/features/themes/i18n/{ru,en}.ts`, tests; `src/pages/themes/ThemesPage.tsx`; route `/themes` (public).
|
||||
|
||||
**Interfaces:**
|
||||
- `codec.ts`: `toProfileJson(entry: ThemeEntry): string` — exactly the mod's `ThemesProfileCodec.toProfileValues` map (keys `name, windowBg, windowHeader, windowStroke, surface, surfaceHover, cardEnabled, cardDisabled, textPrimary, textMuted, accent, accentSoft, strokeSoft`, and for each of `window, header, surface, card, stroke`: `<p>GradientEnabled` (bool), `<p>GradientStart`, `<p>GradientEnd` (`#AARRGGBB` uppercase), `<p>GradientAngle` (number)); `fromProfileJson(json: string): ThemeEntry | { error: string }` (tolerant like the mod: missing keys fall back to the Classic preset); `toShareHash(entry): string` (base64url of the compact JSON) and `fromShareHash(hash)`.
|
||||
- Editor layout: left — live `ClickGuiWindow` + two HUD widgets on the `VoxelScene` (large); right — preset picker, 12 `ColorField`s (native `<input type="color">` + alpha slider + hex input accepting `#RRGGBB`/`#AARRGGBB`), 5 `GradientField`s (toggle, two colors, angle dial 0–360), name field. Actions: «Скопировать для мода» (JSON to clipboard), «Скачать .json», «Импорт» (paste/file), «Ссылка на тему» (URL with `#t=<hash>`; opening such a URL loads the theme). Undo/redo (Ctrl+Z / Ctrl+Shift+Z) over the last 50 edits.
|
||||
- Mod side (NOT in this plan — added to the mod integration plan): `%theme import` reads the JSON from the clipboard via the same codec.
|
||||
|
||||
- [ ] **Step 1: Generator + test** that `PRESETS` contains Classic with accent `#FF5CC8E7` and that the count equals the number of `ThemeEntry` constants in the Java files.
|
||||
- [ ] **Step 2: Failing tests** — codec round-trip (`fromProfileJson(toProfileJson(p))` deep-equals `p` for every preset); JSON keys match the mod's list exactly; editing a color updates the preview's `--gui-accent`; share hash round-trip; malformed import shows an error, keeps the current theme.
|
||||
- [ ] **Step 3: Implement; commit** — `feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links`
|
||||
|
||||
---
|
||||
|
||||
### Task 5E: Public profiles (`/u/:id`) — after configs-service is live
|
||||
|
||||
**Backend prerequisite (added to backend plans as their own tasks):** accounts-service `GET /users/{id}` public → `{ id, display_nick, avatar_url, created_at, badges: string[] }` (badges computed: `early` for the first 1000 accounts, `sharer` when the user has ≥1 published config — the latter via configs gRPC later; start with `early` only); configs-service `GET /showcase?author={id}`.
|
||||
|
||||
**Files:** `src/features/profile/{api.ts,ProfileHeader.tsx,ProfileConfigs.tsx}`, `i18n/`, `tests/`, `src/pages/profile/ProfilePage.tsx`; showcase author names and TopBar nick link to `/u/:id`.
|
||||
|
||||
**Interfaces:** header — large avatar (or initial on an accent-gradient disc), nick (Unbounded), «С нами с {date}», badge chips with tooltips; below — the user's published configs using the showcase `ListingItem`; own profile shows «Редактировать» → `/account`. 404 → «Такого игрока нет».
|
||||
|
||||
- [ ] **Steps:** failing tests (renders header + configs, 404 message, own-profile edit link) → implement → commit `feat(frontend): public player profiles`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Link the game (`/link`)
|
||||
|
||||
**Files:** Create `src/features/device-link/{api.ts,LinkDeviceForm.tsx}`, `src/features/device-link/tests/link.test.tsx`, `src/pages/app/LinkPage.tsx`; modify `src/routes.tsx`.
|
||||
|
|
@ -1265,6 +1414,20 @@ test('russian plural for copies', () => {
|
|||
|
||||
---
|
||||
|
||||
### Task 11: Easter eggs (owner-approved, see TODO.md «Пасхалки»)
|
||||
|
||||
**Files:** `src/features/easter/{konami.ts,devtoolsBanner.ts,BreakableBlock.tsx}`, `src/features/easter/i18n/{ru,en}.ts`, `src/features/easter/tests/easter.test.tsx`; modify `pages/public/NotFoundPage.tsx`, landing Hero/ClickGui (Konami hook), `src/main.tsx` (banner).
|
||||
|
||||
**Interfaces / behaviour:**
|
||||
- `useKonami(onTrigger)`: listens to `keydown` for ↑↑↓↓←→←→BA (`KeyB`, `KeyA`), ignores keys typed inside inputs/textareas; on trigger the landing ClickGui toggles `TrollfaceMask` on, switches to a cycling rainbow accent (hue rotates over 6s, static under reduced motion) and shows a toast «Режим тролля включён» (ru) / «Troll mode on» (en); a second Konami reverts.
|
||||
- `printDevtoolsBanner()`: called once from `main.tsx` in production and dev (not in tests): `console.log('%c<ASCII LoVisual logo>', 'color:#5cc8e7;font-family:monospace')` + `console.log('%cШаришь? Исходники аддонов — на витрине: <origin>/addons', …)` (en variant by current language).
|
||||
- 404 page: an empty dusk voxel world (reuse `VoxelScene`) where one grass block falls from the top with a small bounce (`motion`), then sits; clicking/pressing Enter on it plays a crack animation over 3 clicks (Minecraft-style break stages drawn with CSS), then bursts into square particles and a new block falls. Text «Такой страницы нет. Зато есть блок.» + link «На главную». Keyboard accessible (`button`), reduced motion → no fall/burst, just crack stages.
|
||||
- `IDDQD`: the configs page / share-code input and showcase «загрузить по коду» (when added) accept `IDDQD` (case-insensitive) — backend serves it (configs-service Task 7); no frontend special-casing needed beyond not rejecting 5-char codes client-side.
|
||||
|
||||
- [ ] Tests: Konami sequence triggers the callback, inputs are ignored; banner prints two `console.log` calls with `%c`; 404 block breaks after 3 activations. Implement; commit `feat(frontend): easter eggs — konami troll mode, devtools banner, breakable 404 block`.
|
||||
|
||||
---
|
||||
|
||||
## Deferred
|
||||
|
||||
- Admin panel (`/admin`) — Подсистема 3 plan.
|
||||
|
|
|
|||
|
|
@ -5,13 +5,18 @@
|
|||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/comfortaa": "^5.3.0",
|
||||
"@fontsource/iosevka": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource-variable/onest": "^5.3.1",
|
||||
"@fontsource-variable/unbounded": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"motion": "^13.4.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.4.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
},
|
||||
|
|
@ -60,11 +65,11 @@
|
|||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
|
||||
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="],
|
||||
|
||||
"@fontsource/comfortaa": ["@fontsource/comfortaa@5.3.0", "", {}, "sha512-ab4DERH0q6ko8QCpEZzOV9ebK39LgQ2oM91AkLNqu0VIxxwT0nV2D3sOuI7iLirWWq4IMooaIN6/2llxrDN2JA=="],
|
||||
"@fontsource-variable/onest": ["@fontsource-variable/onest@5.3.1", "", {}, "sha512-xaaw6G4HDB2/RczsWLD/2bnib4ykmQlTlBUN3GOEOiyErilZukQWVJG95h9zwAy0i8KY+QzDa+COPfqhI72Acw=="],
|
||||
|
||||
"@fontsource/iosevka": ["@fontsource/iosevka@5.3.0", "", {}, "sha512-RcG0v/65e4PTuThR/d7n9jj6PLlex7YUGaLa4ZpL+NnqArWQmD8Z+6gYBTxPMVWxYMRcEw6OF4rdt8XisnC5zA=="],
|
||||
"@fontsource-variable/unbounded": ["@fontsource-variable/unbounded@5.3.0", "", {}, "sha512-ea565Ncn4r7gPdBEHcmaF31V11hDfkL7b/JCklLobQbMfbu2ZZQrVWNgdeEFWFLqNh36yhieZWOgxdAnMTzrLg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
|
|
@ -196,16 +201,28 @@
|
|||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
|
||||
|
||||
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
|
@ -246,6 +263,8 @@
|
|||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@5.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q=="],
|
||||
|
|
@ -260,10 +279,24 @@
|
|||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
|
@ -274,12 +307,18 @@
|
|||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
|
||||
|
|
@ -288,20 +327,50 @@
|
|||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"framer-motion": ["framer-motion@13.4.3", "", { "dependencies": { "motion-dom": "^13.4.2", "motion-utils": "^13.3.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="],
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
|
@ -334,22 +403,92 @@
|
|||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"motion": ["motion@13.4.3", "", { "dependencies": { "framer-motion": "^13.4.3", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-Js4rk+VUxSRMKOFT4hOMyMKlxyF11ee9jomiLT3uuwMOU88EoSro0ZEbTYes4m457pqZQ2G3RsLdHQzoEDyBCQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@13.4.2", "", { "dependencies": { "motion-utils": "^13.3.0" } }, "sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w=="],
|
||||
|
||||
"motion-utils": ["motion-utils@13.3.0", "", {}, "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
||||
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
|
||||
|
||||
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
|
@ -360,18 +499,28 @@
|
|||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||
|
||||
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="],
|
||||
|
|
@ -384,12 +533,20 @@
|
|||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
|
@ -408,12 +565,36 @@
|
|||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
|
||||
|
||||
"vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="],
|
||||
|
|
@ -432,6 +613,8 @@
|
|||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
|
@ -454,6 +637,8 @@
|
|||
|
||||
"data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
|
|
|||
|
|
@ -9,16 +9,22 @@
|
|||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"gen:themes": "bun scripts/extract-themes.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/comfortaa": "^5.3.0",
|
||||
"@fontsource/iosevka": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource-variable/onest": "^5.3.1",
|
||||
"@fontsource-variable/unbounded": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"motion": "^13.4.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.4.0",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
|
|
|
|||
165
frontend/scripts/extract-themes.ts
Normal file
165
frontend/scripts/extract-themes.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Generates src/features/themes/presets.generated.ts from the mod's built-in theme presets.
|
||||
// Usage: bun run gen:themes
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const THEME_DIR = resolve(here, '../../mod/src/main/java/dev/loki/lovisual/features/theme')
|
||||
const PRESET_FILES = [1, 2, 3].map((n) => resolve(THEME_DIR, `presets/ThemePresets${n}.java`))
|
||||
const OUT = resolve(here, '../src/features/themes/presets.generated.ts')
|
||||
|
||||
const COLOR_KEYS = [
|
||||
'windowBg', 'windowHeader', 'windowStroke', 'surface', 'surfaceHover', 'cardEnabled',
|
||||
'cardDisabled', 'textPrimary', 'textMuted', 'accent', 'accentSoft', 'strokeSoft',
|
||||
] as const
|
||||
const GRADIENT_KEYS = ['windowGradient', 'headerGradient', 'surfaceGradient', 'cardGradient', 'strokeGradient'] as const
|
||||
|
||||
type Colors = Record<(typeof COLOR_KEYS)[number], number>
|
||||
type Gradient = { enabled: boolean; start: number; end: number; angle: number }
|
||||
|
||||
const stripComments = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
||||
|
||||
/** Splits a comma-separated argument list, respecting nested parentheses. */
|
||||
function splitArgs(args: string): string[] {
|
||||
const out: string[] = []
|
||||
let depth = 0
|
||||
let cur = ''
|
||||
for (const ch of args) {
|
||||
if (ch === '(') depth++
|
||||
if (ch === ')') depth--
|
||||
if (ch === ',' && depth === 0) {
|
||||
out.push(cur.trim())
|
||||
cur = ''
|
||||
} else cur += ch
|
||||
}
|
||||
if (cur.trim()) out.push(cur.trim())
|
||||
return out
|
||||
}
|
||||
|
||||
/** Finds `static final <type> NAME = new <type>(...)` declarations and returns their raw argument lists. */
|
||||
function declarations(src: string, type: string): Map<string, string[]> {
|
||||
const found = new Map<string, string[]>()
|
||||
const re = new RegExp(`static\\s+final\\s+${type}\\s+(\\w+)\\s*=\\s*new\\s+${type}\\s*\\(`, 'g')
|
||||
for (let m = re.exec(src); m; m = re.exec(src)) {
|
||||
let depth = 1
|
||||
let i = re.lastIndex
|
||||
for (; depth > 0; i++) {
|
||||
if (src[i] === '(') depth++
|
||||
if (src[i] === ')') depth--
|
||||
}
|
||||
found.set(m[1], splitArgs(src.slice(re.lastIndex, i - 1)))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const parseInt32 = (lit: string) => Number.parseInt(lit.replace(/^0x/i, ''), 16) >>> 0
|
||||
|
||||
// Port of ThemesBlending.adjustBrightness (Java Math.round(float) == floor(x + 0.5)).
|
||||
function adjustBrightness(argb: number, delta: number): number {
|
||||
const ch = (v: number) => {
|
||||
const moved = v + Math.floor((delta >= 0 ? 255 - v : v) * delta + 0.5)
|
||||
return Math.min(255, Math.max(0, moved))
|
||||
}
|
||||
const a = (argb >>> 24) & 0xff
|
||||
return ((a << 24) | (ch((argb >>> 16) & 0xff) << 16) | (ch((argb >>> 8) & 0xff) << 8) | ch(argb & 0xff)) >>> 0
|
||||
}
|
||||
|
||||
function resolveColor(expr: string, themes: Map<string, Colors>): number {
|
||||
if (/^0x[0-9a-f]+$/i.test(expr)) return parseInt32(expr)
|
||||
const ref = /^(\w+)\.(\w+)\(\)$/.exec(expr)
|
||||
const colors = ref ? themes.get(ref[1]) : undefined
|
||||
if (!ref || !colors || !(ref[2] in colors)) throw new Error(`Cannot resolve color: ${expr}`)
|
||||
return colors[ref[2] as keyof Colors]
|
||||
}
|
||||
|
||||
function parseGradient(expr: string, themes: Map<string, Colors>): Gradient {
|
||||
const call = /^(?:Themes\.)?(flatGradient|subtleHeaderGradient)\((.+)\)$/.exec(expr)
|
||||
if (call) {
|
||||
const color = resolveColor(call[2].trim(), themes)
|
||||
if (call[1] === 'flatGradient') return { enabled: false, start: color, end: color, angle: 90 }
|
||||
return { enabled: true, start: adjustBrightness(color, 0.08), end: adjustBrightness(color, -0.06), angle: 90 }
|
||||
}
|
||||
const spec = /^new\s+GradientSpec\((.+)\)$/.exec(expr)
|
||||
if (!spec) throw new Error(`Unknown gradient expression: ${expr}`)
|
||||
const [enabled, start, end, angle] = splitArgs(spec[1])
|
||||
return {
|
||||
enabled: enabled === 'true',
|
||||
start: resolveColor(start, themes),
|
||||
end: resolveColor(end, themes),
|
||||
angle: Number.parseFloat(angle.replace(/f$/i, '')),
|
||||
}
|
||||
}
|
||||
|
||||
const hex = (argb: number) => `#${argb.toString(16).toUpperCase().padStart(8, '0')}`
|
||||
|
||||
export type ParsedEntry = { const: string; id: string; name: string; builtin: boolean; colors: Colors; gradients: Gradient[] }
|
||||
|
||||
/** Parses the preset sources (and Themes.java for id constants and display order). */
|
||||
export function parsePresets(presetSources: string[], themesSource: string): ParsedEntry[] {
|
||||
const themesJava = stripComments(themesSource)
|
||||
const ids = new Map<string, string>()
|
||||
for (const m of themesJava.matchAll(/static\s+final\s+String\s+(THEME_\w+)\s*=\s*"([^"]+)"/g)) ids.set(m[1], m[2])
|
||||
|
||||
const entries: ParsedEntry[] = []
|
||||
for (const raw of presetSources) {
|
||||
const src = stripComments(raw)
|
||||
const themes = new Map<string, Colors>()
|
||||
for (const [name, args] of declarations(src, 'Theme')) {
|
||||
if (args.length !== COLOR_KEYS.length) throw new Error(`Theme ${name}: expected 12 colors, got ${args.length}`)
|
||||
themes.set(name, Object.fromEntries(COLOR_KEYS.map((k, i) => [k, parseInt32(args[i])])) as Colors)
|
||||
}
|
||||
for (const [constName, args] of declarations(src, 'ThemeEntry')) {
|
||||
const [idExpr, nameExpr, builtin, themeRef, ...grads] = args
|
||||
const idConst = idExpr.replace(/^Themes\./, '')
|
||||
const colors = themes.get(themeRef)
|
||||
if (!colors) throw new Error(`ThemeEntry ${constName}: unknown theme ${themeRef}`)
|
||||
entries.push({
|
||||
const: constName,
|
||||
id: ids.get(idConst) ?? idExpr.replace(/^"|"$/g, ''),
|
||||
name: nameExpr.replace(/^"|"$/g, ''),
|
||||
builtin: builtin === 'true',
|
||||
colors,
|
||||
gradients: grads.map((g) => parseGradient(g, themes)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Follow the mod's picker order (Themes.PRESET_LIST) where available.
|
||||
const order = /PRESET_LIST\s*=\s*List\.of\(([\s\S]*?)\);/.exec(themesJava)?.[1] ?? ''
|
||||
const rank = (e: ParsedEntry) => {
|
||||
const i = order.indexOf(`.${e.const}`)
|
||||
return i < 0 ? Number.MAX_SAFE_INTEGER : i
|
||||
}
|
||||
return entries.map((e, i) => ({ e, i })).sort((a, b) => rank(a.e) - rank(b.e) || a.i - b.i).map(({ e }) => e)
|
||||
}
|
||||
|
||||
export function render(entries: ParsedEntry[]): string {
|
||||
const body = entries.map((e) => {
|
||||
const theme = COLOR_KEYS.map((k) => ` ${k}: '${hex(e.colors[k])}',`).join('\n')
|
||||
const grads = GRADIENT_KEYS.map((k, i) => {
|
||||
const g = e.gradients[i]
|
||||
return ` ${k}: { enabled: ${g.enabled}, start: '${hex(g.start)}', end: '${hex(g.end)}', angle: ${g.angle} },`
|
||||
}).join('\n')
|
||||
return ` {\n id: '${e.id}',\n name: '${e.name}',\n builtin: ${e.builtin},\n theme: {\n${theme}\n },\n${grads}\n },`
|
||||
})
|
||||
return [
|
||||
'// generated — do not edit. Source: mod/.../features/theme/presets/ThemePresets{1,2,3}.java',
|
||||
'// Regenerate with `bun run gen:themes`.',
|
||||
"import type { ThemeEntry } from './types'",
|
||||
'',
|
||||
'export const PRESETS: ThemeEntry[] = [',
|
||||
...body,
|
||||
']',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const entries = parsePresets(
|
||||
PRESET_FILES.map((f) => readFileSync(f, 'utf8')),
|
||||
readFileSync(resolve(THEME_DIR, 'Themes.java'), 'utf8'),
|
||||
)
|
||||
writeFileSync(OUT, render(entries))
|
||||
console.log(`Wrote ${entries.length} presets to ${OUT}`)
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
|
||||
const router = createBrowserRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
20
frontend/src/app/App.tsx
Normal file
20
frontend/src/app/App.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
import { SessionProvider } from '../features/auth/session'
|
||||
import { ActiveThemeProvider } from '../features/themes/useActiveTheme'
|
||||
import { routes } from './routes'
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 } } })
|
||||
const router = createBrowserRouter(routes)
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<ActiveThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ActiveThemeProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
43
frontend/src/app/i18n.ts
Normal file
43
frontend/src/app/i18n.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import i18next, { type i18n } from 'i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { authEn } from '../features/auth/i18n/en'
|
||||
import { authRu } from '../features/auth/i18n/ru'
|
||||
import { clickguiEn } from '../features/clickgui/i18n/en'
|
||||
import { clickguiRu } from '../features/clickgui/i18n/ru'
|
||||
import { downloadEn } from '../features/download/i18n/en'
|
||||
import { downloadRu } from '../features/download/i18n/ru'
|
||||
import { landingEn } from '../features/landing/i18n/en'
|
||||
import { landingRu } from '../features/landing/i18n/ru'
|
||||
import { themesEn } from '../features/themes/i18n/en'
|
||||
import { themesRu } from '../features/themes/i18n/ru'
|
||||
import { commonEn } from '../shared/i18n/common.en'
|
||||
import { commonRu } from '../shared/i18n/common.ru'
|
||||
|
||||
/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */
|
||||
export function initI18n(lng?: 'ru' | 'en'): i18n {
|
||||
const instance = i18next.createInstance()
|
||||
instance
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng,
|
||||
resources: {
|
||||
ru: { common: commonRu, auth: authRu, landing: landingRu, themes: themesRu, clickgui: clickguiRu, download: downloadRu },
|
||||
en: { common: commonEn, auth: authEn, landing: landingEn, themes: themesEn, clickgui: clickguiEn, download: downloadEn },
|
||||
},
|
||||
fallbackLng: 'ru',
|
||||
supportedLngs: ['ru', 'en'],
|
||||
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'],
|
||||
defaultNS: 'common',
|
||||
interpolation: { escapeValue: false },
|
||||
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },
|
||||
})
|
||||
|
||||
document.documentElement.lang = instance.language
|
||||
instance.on('languageChanged', (next) => {
|
||||
document.documentElement.lang = next
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
21
frontend/src/app/i18next.d.ts
vendored
Normal file
21
frontend/src/app/i18next.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { authRu } from '../features/auth/i18n/ru'
|
||||
import type { clickguiRu } from '../features/clickgui/i18n/ru'
|
||||
import type { downloadRu } from '../features/download/i18n/ru'
|
||||
import type { landingRu } from '../features/landing/i18n/ru'
|
||||
import type { themesRu } from '../features/themes/i18n/ru'
|
||||
import type { commonRu } from '../shared/i18n/common.ru'
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common'
|
||||
resources: {
|
||||
common: typeof commonRu
|
||||
auth: typeof authRu
|
||||
landing: typeof landingRu
|
||||
themes: typeof themesRu
|
||||
clickgui: typeof clickguiRu
|
||||
download: typeof downloadRu
|
||||
// Later tasks extend this with their own feature namespaces.
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/routes.tsx
Normal file
25
frontend/src/app/routes.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { RouteObject } from 'react-router'
|
||||
import { RequireAuth } from '../features/auth/RequireAuth'
|
||||
import { AppShell } from '../shared/layout/AppShell'
|
||||
import HomePage from '../pages/public/HomePage'
|
||||
import LoginPage from '../pages/public/LoginPage'
|
||||
import RegisterPage from '../pages/public/RegisterPage'
|
||||
import NotFoundPage from '../pages/public/NotFoundPage'
|
||||
// Code-split: the download page pulls in react-markdown, so it loads on demand.
|
||||
import DownloadRoute from '../pages/download/DownloadRoute'
|
||||
import ThemesRoute from '../pages/themes/ThemesRoute'
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/register', element: <RegisterPage /> },
|
||||
{ path: '/download', element: <DownloadRoute /> },
|
||||
{ path: '/themes', element: <ThemesRoute /> },
|
||||
{ element: <RequireAuth />, children: [] }, // Tasks 6–8 add /link, /account, /configs
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
import { useSession } from './session'
|
||||
|
||||
export function RequireAuth() {
|
||||
const { t } = useTranslation('auth')
|
||||
const { status } = useSession()
|
||||
const location = useLocation()
|
||||
if (status === 'loading') return <p className="text-frost">Проверяем вход…</p>
|
||||
if (status === 'loading') return <p className="text-frost">{t('checkingSession')}</p>
|
||||
if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
return <Outlet />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
|
|
@ -7,6 +8,8 @@ import { TextField } from '../../../shared/ui/TextField'
|
|||
import { useSession } from '../session'
|
||||
|
||||
export function LoginForm() {
|
||||
const { t } = useTranslation('auth')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const { signIn } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
|
||||
|
|
@ -23,7 +26,7 @@ export function LoginForm() {
|
|||
await signIn(email.trim(), password)
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 401: 'Неверная почта или пароль.' }))
|
||||
setError(describeError(tc, err, { 401: t('login.wrongCredentials') }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -31,9 +34,15 @@ export function LoginForm() {
|
|||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField label="Почта" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<TextField
|
||||
label="Пароль"
|
||||
label={t('login.emailLabel')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t('login.passwordLabel')}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
|
|
@ -41,10 +50,13 @@ export function LoginForm() {
|
|||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Войти
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Нет аккаунта? <Link to="/register" className="text-ice underline-offset-4 hover:underline">Создать</Link>
|
||||
{t('login.noAccount')}{' '}
|
||||
<Link to="/register" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('login.createAccount')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
|
|
@ -8,6 +9,8 @@ import { useSession } from '../session'
|
|||
import { validateEmail, validateNick, validatePassword } from '../validation'
|
||||
|
||||
export function RegisterForm() {
|
||||
const { t } = useTranslation('auth')
|
||||
const { t: tc } = useTranslation('common')
|
||||
const { signUp } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
|
|
@ -23,9 +26,9 @@ export function RegisterForm() {
|
|||
e.preventDefault()
|
||||
setError(undefined)
|
||||
|
||||
const emailErr = validateEmail(email)
|
||||
const nickErr = validateNick(nick)
|
||||
const passwordErr = validatePassword(password)
|
||||
const emailErr = validateEmail(email, t)
|
||||
const nickErr = validateNick(nick, t)
|
||||
const passwordErr = validatePassword(password, t)
|
||||
setEmailError(emailErr)
|
||||
setNickError(nickErr)
|
||||
setPasswordError(passwordErr)
|
||||
|
|
@ -36,7 +39,7 @@ export function RegisterForm() {
|
|||
await signUp(email.trim(), password, nick.trim())
|
||||
navigate('/configs', { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 409: 'Эта почта уже занята. Войди или используй другую.' }))
|
||||
setError(describeError(tc, err, { 409: t('register.emailTaken') }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -45,7 +48,7 @@ export function RegisterForm() {
|
|||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField
|
||||
label="Почта"
|
||||
label={t('register.emailLabel')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
|
|
@ -53,28 +56,31 @@ export function RegisterForm() {
|
|||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Ник на сайте"
|
||||
label={t('register.nickLabel')}
|
||||
autoComplete="nickname"
|
||||
value={nick}
|
||||
error={nickError}
|
||||
hint={nickError ? undefined : 'Не обязательно совпадает с ником в Minecraft.'}
|
||||
hint={nickError ? undefined : t('register.nickHint')}
|
||||
onChange={(e) => setNick(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
label={t('register.passwordLabel')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
error={passwordError}
|
||||
hint={passwordError ? undefined : 'Минимум 8 символов.'}
|
||||
hint={passwordError ? undefined : t('register.passwordHint')}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Создать аккаунт
|
||||
{t('register.submit')}
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Уже есть аккаунт? <Link to="/login" className="text-ice underline-offset-4 hover:underline">Войти</Link>
|
||||
{t('register.haveAccount')}{' '}
|
||||
<Link to="/login" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('register.login')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
36
frontend/src/features/auth/i18n/en.ts
Normal file
36
frontend/src/features/auth/i18n/en.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { authRu } from './ru'
|
||||
|
||||
export const authEn = {
|
||||
checkingSession: 'Checking session…',
|
||||
login: {
|
||||
title: 'Log in',
|
||||
emailLabel: 'Email',
|
||||
passwordLabel: 'Password',
|
||||
submit: 'Log in',
|
||||
noAccount: "Don't have an account?",
|
||||
createAccount: 'Sign up',
|
||||
wrongCredentials: 'Wrong email or password.',
|
||||
},
|
||||
register: {
|
||||
title: 'New account',
|
||||
subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.",
|
||||
emailLabel: 'Email',
|
||||
nickLabel: 'Site nickname',
|
||||
nickHint: "Doesn't have to match your Minecraft nick.",
|
||||
passwordLabel: 'Password',
|
||||
passwordHint: 'At least 8 characters.',
|
||||
submit: 'Create account',
|
||||
haveAccount: 'Already have an account?',
|
||||
login: 'Log in',
|
||||
emailTaken: 'That email is already taken. Log in or use another one.',
|
||||
},
|
||||
validation: {
|
||||
emailRequired: 'Enter your email.',
|
||||
emailInvalid: 'That email looks off.',
|
||||
passwordShort: 'At least 8 characters.',
|
||||
passwordTooLong: 'Password is too long.',
|
||||
nickRequired: 'Pick a nickname.',
|
||||
nickTooLong: 'No longer than 32 characters.',
|
||||
},
|
||||
} satisfies Translation<typeof authRu>
|
||||
33
frontend/src/features/auth/i18n/ru.ts
Normal file
33
frontend/src/features/auth/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const authRu = {
|
||||
checkingSession: 'Проверяем вход…',
|
||||
login: {
|
||||
title: 'Вход',
|
||||
emailLabel: 'Почта',
|
||||
passwordLabel: 'Пароль',
|
||||
submit: 'Войти',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
createAccount: 'Создать',
|
||||
wrongCredentials: 'Неверная почта или пароль.',
|
||||
},
|
||||
register: {
|
||||
title: 'Новый аккаунт',
|
||||
subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.',
|
||||
emailLabel: 'Почта',
|
||||
nickLabel: 'Ник на сайте',
|
||||
nickHint: 'Не обязательно совпадает с ником в Minecraft.',
|
||||
passwordLabel: 'Пароль',
|
||||
passwordHint: 'Минимум 8 символов.',
|
||||
submit: 'Создать аккаунт',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
login: 'Войти',
|
||||
emailTaken: 'Эта почта уже занята. Войди или используй другую.',
|
||||
},
|
||||
validation: {
|
||||
emailRequired: 'Введи почту.',
|
||||
emailInvalid: 'Похоже, в почте опечатка.',
|
||||
passwordShort: 'Минимум 8 символов.',
|
||||
passwordTooLong: 'Слишком длинный пароль.',
|
||||
nickRequired: 'Придумай ник.',
|
||||
nickTooLong: 'Не длиннее 32 символов.',
|
||||
},
|
||||
} as const
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
export function validateEmail(email: string): string | undefined {
|
||||
import type { TFunction } from 'i18next'
|
||||
|
||||
export function validateEmail(email: string, t: TFunction<'auth'>): string | undefined {
|
||||
const v = email.trim()
|
||||
if (!v) return 'Введи почту.'
|
||||
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return 'Похоже, в почте опечатка.'
|
||||
if (!v) return t('validation.emailRequired')
|
||||
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return t('validation.emailInvalid')
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): string | undefined {
|
||||
if ([...password].length < 8) return 'Минимум 8 символов.'
|
||||
if (new TextEncoder().encode(password).length > 256) return 'Слишком длинный пароль.'
|
||||
export function validatePassword(password: string, t: TFunction<'auth'>): string | undefined {
|
||||
if ([...password].length < 8) return t('validation.passwordShort')
|
||||
if (new TextEncoder().encode(password).length > 256) return t('validation.passwordTooLong')
|
||||
}
|
||||
|
||||
export function validateNick(nick: string): string | undefined {
|
||||
export function validateNick(nick: string, t: TFunction<'auth'>): string | undefined {
|
||||
const len = [...nick.trim()].length
|
||||
if (len === 0) return 'Придумай ник.'
|
||||
if (len > 32) return 'Не длиннее 32 символов.'
|
||||
if (len === 0) return t('validation.nickRequired')
|
||||
if (len > 32) return t('validation.nickTooLong')
|
||||
}
|
||||
|
|
|
|||
130
frontend/src/features/clickgui/ClickGuiWindow.tsx
Normal file
130
frontend/src/features/clickgui/ClickGuiWindow.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
|
||||
import { PRESETS } from '../themes/presets.generated'
|
||||
import type { ThemeEntry } from '../themes/types'
|
||||
import { useActiveTheme } from '../themes/useActiveTheme'
|
||||
import { DEMO, useDemo } from './demo'
|
||||
import { ModuleCard } from './ModuleCard'
|
||||
import './parts/clickgui.css'
|
||||
import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs'
|
||||
import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules'
|
||||
import { themeToCssVars } from './themeVars'
|
||||
|
||||
type Props = {
|
||||
/** Runs the scripted cursor demo (pauses while hovered or when `paused`). */
|
||||
demo?: boolean
|
||||
paused?: boolean
|
||||
/** Overrides the active theme — for previews. */
|
||||
theme?: ThemeEntry
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ClickGuiWindow({ demo = true, paused = false, theme: override, className = '' }: Props) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
const { theme: active, setTheme } = useActiveTheme()
|
||||
const theme = override ?? active
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [reduced] = useState(prefersReducedMotion)
|
||||
const themeIdRef = useRef(theme.id)
|
||||
useEffect(() => {
|
||||
themeIdRef.current = theme.id
|
||||
}, [theme.id])
|
||||
|
||||
const { cursor, state, dispatch } = useDemo(DEMO, {
|
||||
paused: !demo || paused || hovered,
|
||||
reduced,
|
||||
rootRef,
|
||||
nextTheme: () => {
|
||||
const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number])
|
||||
return QUICK_THEMES[(i + 1) % QUICK_THEMES.length]
|
||||
},
|
||||
onTheme: (id) => setTheme(id, 'demo'),
|
||||
})
|
||||
|
||||
const vars = useMemo(() => themeToCssVars(theme), [theme])
|
||||
const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
role="group"
|
||||
aria-label={t('windowLabel')}
|
||||
data-testid="clickgui-root"
|
||||
className={`lv-gui ${className}`}
|
||||
style={vars}
|
||||
onPointerEnter={(e) => e.pointerType === 'mouse' && setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="lv-gui-bloom" aria-hidden="true" />
|
||||
<div className="lv-gui-window">
|
||||
<header className="lv-gui-header">
|
||||
<span className="lv-gui-logo">
|
||||
<LogoMark />
|
||||
LoVisual
|
||||
</span>
|
||||
<nav className="lv-gui-tabs" aria-hidden="true">
|
||||
{TABS.map((tab, i) => (
|
||||
<span key={tab} className="lv-gui-tab" data-active={i === 0}>
|
||||
{tab}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
<span className="lv-gui-search" aria-hidden="true">
|
||||
<SearchGlyph />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="lv-gui-body">
|
||||
<aside className="lv-gui-cats" aria-label={t('categories')}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="lv-gui-cat"
|
||||
data-active={c.id === category.id}
|
||||
aria-pressed={c.id === category.id}
|
||||
data-demo={`cat:${c.id}`}
|
||||
aria-label={c.name}
|
||||
onClick={() => dispatch({ type: 'category', id: c.id })}
|
||||
>
|
||||
<CategoryIcon id={c.id} />
|
||||
<span className="lv-gui-cat-name">{c.name}</span>
|
||||
<span className="lv-gui-cat-count">{c.modules.length}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="lv-gui-theme-dots">
|
||||
{QUICK_THEMES.map((id) => {
|
||||
const preset = PRESETS.find((p) => p.id === id)
|
||||
if (!preset) return null
|
||||
const dot = themeToCssVars(preset)
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="lv-gui-theme-dot"
|
||||
data-active={theme.id === id}
|
||||
data-demo={`theme:${id}`}
|
||||
aria-label={t('theme', { name: preset.name })}
|
||||
style={{ '--dot': dot['--gui-header-gradient'], '--dot-accent': dot['--gui-accent'] } as CSSProperties}
|
||||
onClick={() => setTheme(id, 'user')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="lv-gui-main">
|
||||
<div className="lv-gui-grid" key={category.id}>
|
||||
{category.modules.map((m) => (
|
||||
<ModuleCard key={m.name} module={m} state={state} dispatch={dispatch} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DemoCursor cursor={cursor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/features/clickgui/ModuleCard.tsx
Normal file
59
frontend/src/features/clickgui/ModuleCard.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { Dispatch } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GuiAction, GuiState } from './demo'
|
||||
import { Chevron } from './parts/Glyphs'
|
||||
import type { GuiModule } from './parts/modules'
|
||||
import { SettingControls } from './parts/SettingControls'
|
||||
|
||||
type Props = { module: GuiModule; state: GuiState; dispatch: Dispatch<GuiAction> }
|
||||
|
||||
/** One module card: bullet title, description, divider, bind + settings chevron + status toggle. */
|
||||
export function ModuleCard({ module, state, dispatch }: Props) {
|
||||
const { t } = useTranslation('clickgui')
|
||||
const { name, settings } = module
|
||||
const on = Boolean(state.enabled[name])
|
||||
const open = state.expanded === name && Boolean(settings)
|
||||
|
||||
return (
|
||||
<article className="lv-gui-card" data-on={on} data-open={open}>
|
||||
<span className="lv-gui-card-glow" aria-hidden="true" />
|
||||
<h3 className="lv-gui-card-title">
|
||||
<span aria-hidden="true">• </span>
|
||||
{name}
|
||||
</h3>
|
||||
<p className="lv-gui-card-desc">{t(`desc.${name}`)}</p>
|
||||
<div className="lv-gui-card-divider" aria-hidden="true" />
|
||||
<div className="lv-gui-card-foot">
|
||||
<span className="lv-gui-bind">
|
||||
{t('bind')}: <span>{t('bindNone')}</span>
|
||||
</span>
|
||||
{settings ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lv-gui-expand"
|
||||
aria-expanded={open}
|
||||
aria-label={t('settings', { name })}
|
||||
data-demo={`expand:${name}`}
|
||||
onClick={() => dispatch({ type: 'expand', module: name })}
|
||||
>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="lv-gui-bind lv-gui-nosettings">{t('noSettings')}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={t('toggle', { name })}
|
||||
data-demo={`toggle:${name}`}
|
||||
className="lv-gui-toggle"
|
||||
onClick={() => dispatch({ type: 'toggle', module: name })}
|
||||
>
|
||||
<span className="lv-gui-toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
{open && settings ? <SettingControls module={name} settings={settings} state={state} dispatch={dispatch} /> : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
175
frontend/src/features/clickgui/demo.ts
Normal file
175
frontend/src/features/clickgui/demo.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { CATEGORIES, type CategoryId, settingKey } from './parts/modules'
|
||||
|
||||
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }
|
||||
|
||||
export const LOOP_MS = 12_500
|
||||
|
||||
/** The scripted loop: pick Visuals, switch Trails on, open it, drag Length, tick Rainbow, change theme, tidy up. */
|
||||
export const DEMO: DemoStep[] = [
|
||||
{ at: 400, action: 'move', target: 'cat:visuals' },
|
||||
{ at: 1100, action: 'click', target: 'cat:visuals' },
|
||||
{ at: 1700, action: 'move', target: 'toggle:Trails' },
|
||||
{ at: 2400, action: 'click', target: 'toggle:Trails' },
|
||||
{ at: 3000, action: 'move', target: 'expand:Trails' },
|
||||
{ at: 3600, action: 'click', target: 'expand:Trails' },
|
||||
{ at: 4300, action: 'move', target: 'knob:Trails.Length' },
|
||||
{ at: 4900, action: 'drag', target: 'slider:Trails.Length', value: 85 },
|
||||
{ at: 6300, action: 'move', target: 'check:Trails.Rainbow' },
|
||||
{ at: 6900, action: 'click', target: 'check:Trails.Rainbow' },
|
||||
{ at: 7700, action: 'move', target: 'theme:next' },
|
||||
{ at: 8400, action: 'theme', target: 'theme:next' },
|
||||
{ at: 9400, action: 'move', target: 'expand:Trails' },
|
||||
{ at: 10000, action: 'click', target: 'expand:Trails' },
|
||||
{ at: 10800, action: 'move', target: 'rest' },
|
||||
]
|
||||
|
||||
export type GuiState = {
|
||||
category: CategoryId
|
||||
enabled: Record<string, boolean>
|
||||
expanded: string | null
|
||||
values: Record<string, number | boolean | string>
|
||||
dragging: boolean
|
||||
}
|
||||
|
||||
export type GuiAction =
|
||||
| { type: 'category'; id: CategoryId }
|
||||
| { type: 'toggle'; module: string }
|
||||
| { type: 'expand'; module: string }
|
||||
| { type: 'set'; key: string; value: number | boolean | string }
|
||||
| { type: 'dragging'; on: boolean }
|
||||
| { type: 'reset' }
|
||||
|
||||
export function initialGuiState(finished = false): GuiState {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const values: GuiState['values'] = {}
|
||||
for (const c of CATEGORIES) {
|
||||
for (const m of c.modules) {
|
||||
enabled[m.name] = Boolean(m.on)
|
||||
for (const s of m.settings ?? []) values[settingKey(m.name, s.name)] = s.value
|
||||
}
|
||||
}
|
||||
if (!finished) return { category: 'combat', enabled, expanded: null, values, dragging: false }
|
||||
// The resting "after the demo" frame, used under reduced motion.
|
||||
enabled.Trails = true
|
||||
values[settingKey('Trails', 'Length')] = 85
|
||||
return { category: 'visuals', enabled, expanded: 'Trails', values, dragging: false }
|
||||
}
|
||||
|
||||
export function guiReducer(state: GuiState, action: GuiAction): GuiState {
|
||||
switch (action.type) {
|
||||
case 'category':
|
||||
return { ...state, category: action.id, expanded: null }
|
||||
case 'toggle':
|
||||
return { ...state, enabled: { ...state.enabled, [action.module]: !state.enabled[action.module] } }
|
||||
case 'expand':
|
||||
return { ...state, expanded: state.expanded === action.module ? null : action.module }
|
||||
case 'set':
|
||||
return { ...state, values: { ...state.values, [action.key]: action.value } }
|
||||
case 'dragging':
|
||||
return { ...state, dragging: action.on }
|
||||
case 'reset':
|
||||
return initialGuiState()
|
||||
}
|
||||
}
|
||||
|
||||
export type Cursor = { x: number; y: number; visible: boolean; pressed: boolean; duration: number }
|
||||
|
||||
type Options = {
|
||||
paused: boolean
|
||||
reduced: boolean
|
||||
rootRef: RefObject<HTMLElement | null>
|
||||
/** Resolves `theme:next` to a concrete `theme:<id>` target and applies it. */
|
||||
nextTheme: () => string
|
||||
onTheme: (id: string) => void
|
||||
}
|
||||
|
||||
/** Layout position of `el` inside `root`, ignoring CSS transforms (the window is tilted in 3D). */
|
||||
function offsetWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
|
||||
let x = 0
|
||||
let y = 0
|
||||
let node: HTMLElement | null = el
|
||||
while (node && node !== root) {
|
||||
x += node.offsetLeft
|
||||
y += node.offsetTop
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } {
|
||||
const [state, dispatch] = useReducer(guiReducer, opts.reduced, initialGuiState)
|
||||
const [cursor, setCursor] = useState<Cursor>({ x: 0, y: 0, visible: false, pressed: false, duration: 0 })
|
||||
const [cycle, setCycle] = useState(0)
|
||||
const optsRef = useRef(opts)
|
||||
useEffect(() => {
|
||||
optsRef.current = opts
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (opts.paused || opts.reduced) return
|
||||
const timers: number[] = []
|
||||
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
|
||||
let pendingTheme = ''
|
||||
|
||||
const locate = (target: string, fraction?: number) => {
|
||||
const root = optsRef.current.rootRef.current
|
||||
if (!root) return null
|
||||
if (target === 'rest') return { x: root.offsetWidth * 0.86, y: root.offsetHeight * 0.9 }
|
||||
const resolved = target === 'theme:next' ? (pendingTheme ||= `theme:${optsRef.current.nextTheme()}`) : target
|
||||
const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`)
|
||||
if (!el) return null
|
||||
const { x, y } = offsetWithin(el, root)
|
||||
const fx = fraction ?? 0.5
|
||||
return { x: x + el.offsetWidth * fx, y: y + el.offsetHeight * 0.5 }
|
||||
}
|
||||
|
||||
const move = (target: string, duration: number, fraction?: number) => {
|
||||
const p = locate(target, fraction)
|
||||
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration }))
|
||||
}
|
||||
|
||||
const click = (fn: () => void) => {
|
||||
setCursor((c) => ({ ...c, pressed: true }))
|
||||
later(160, () => {
|
||||
setCursor((c) => ({ ...c, pressed: false }))
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
||||
const run = (step: DemoStep) => {
|
||||
const [kind, arg] = step.target.split(':')
|
||||
if (step.action === 'move') return move(step.target, 650)
|
||||
if (step.action === 'theme') {
|
||||
return click(() => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
|
||||
}
|
||||
if (step.action === 'drag') {
|
||||
const [module, setting] = arg.split('.')
|
||||
const value = Number(step.value ?? 0)
|
||||
setCursor((c) => ({ ...c, pressed: true }))
|
||||
dispatch({ type: 'dragging', on: true })
|
||||
move(step.target, 900, value / 100)
|
||||
dispatch({ type: 'set', key: settingKey(module, setting), value })
|
||||
return later(950, () => {
|
||||
setCursor((c) => ({ ...c, pressed: false }))
|
||||
dispatch({ type: 'dragging', on: false })
|
||||
})
|
||||
}
|
||||
click(() => {
|
||||
if (kind === 'cat') dispatch({ type: 'category', id: arg as CategoryId })
|
||||
if (kind === 'toggle') dispatch({ type: 'toggle', module: arg })
|
||||
if (kind === 'expand') dispatch({ type: 'expand', module: arg })
|
||||
if (kind === 'check') dispatch({ type: 'set', key: arg, value: true })
|
||||
})
|
||||
}
|
||||
|
||||
dispatch({ type: 'reset' })
|
||||
const lead = cycle === 0 ? 600 : 0
|
||||
for (const step of steps) later(step.at + lead, () => run(step))
|
||||
later(LOOP_MS + lead, () => setCycle((n) => n + 1))
|
||||
return () => timers.forEach((t) => window.clearTimeout(t))
|
||||
}, [steps, opts.paused, opts.reduced, cycle])
|
||||
|
||||
const idle = opts.paused || opts.reduced
|
||||
return { cursor: idle ? { ...cursor, visible: false, pressed: false } : cursor, state, dispatch }
|
||||
}
|
||||
49
frontend/src/features/clickgui/i18n/en.ts
Normal file
49
frontend/src/features/clickgui/i18n/en.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { clickguiRu } from './ru'
|
||||
|
||||
export const clickguiEn = {
|
||||
windowLabel: 'Interactive replica of the LoVisual ClickGui',
|
||||
bind: 'Bind',
|
||||
bindNone: 'none',
|
||||
toggle: 'Toggle {{name}}',
|
||||
settings: '{{name}} settings',
|
||||
categories: 'Categories',
|
||||
theme: '{{name}} theme',
|
||||
noSettings: 'No settings',
|
||||
desc: {
|
||||
PvpCooldowns: 'Cooldown timers for shield, pearls and totems',
|
||||
Predictions: 'Flight path of pearls and arrows',
|
||||
TargetESP: 'Animated marker on your current target',
|
||||
DamageTint: 'A colored flash when you take damage',
|
||||
NoFriendDamage: 'Stops you from hitting friends by accident',
|
||||
CombatLog: 'Hit and damage log in chat',
|
||||
TPSSync: 'Syncs timers to the server TPS',
|
||||
ProjectilePuncher: 'Knocks away incoming projectiles',
|
||||
Trails: 'A glowing trail behind the player',
|
||||
JumpCircles: 'Rings on the ground when you jump',
|
||||
HitBubbles: 'Damage bubbles on every hit',
|
||||
AmbientParticles: 'Soft particles floating around you',
|
||||
Wings: 'Animated wings on your back',
|
||||
ChinaHat: 'A cone hat with a gradient',
|
||||
ShaderSky: 'A shader sky with clouds',
|
||||
MotionBlur: 'Blur while you move',
|
||||
PostFX: 'Bloom, vignette and color grading',
|
||||
KillEffect: 'A lightning strike on kills',
|
||||
ThirdPerson: 'A smooth third-person camera',
|
||||
AutoTool: 'Picks the right tool for you',
|
||||
DeathCoords: 'Death coordinates in chat',
|
||||
AutoRespawn: 'Respawn without the death screen',
|
||||
ClickPearl: 'Throw a pearl with a mouse button',
|
||||
LockSlot: 'Protects slots from dropping',
|
||||
UseTracker: 'Counts item usage',
|
||||
NoDelay: 'Removes the jump delay',
|
||||
StreamerMode: 'Hides personal info on stream',
|
||||
NameProtect: 'Replaces your name on screen',
|
||||
HitSounds: 'Pick your own hit sound',
|
||||
ItemScroller: 'Move items around faster',
|
||||
MessageFilter: 'Filters chat spam',
|
||||
Optimize: 'Extra FPS without losing the looks',
|
||||
NoServerPack: 'Declines server resource packs',
|
||||
BetterMinecraft: 'Small interface improvements',
|
||||
},
|
||||
} satisfies Translation<typeof clickguiRu>
|
||||
46
frontend/src/features/clickgui/i18n/ru.ts
Normal file
46
frontend/src/features/clickgui/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export const clickguiRu = {
|
||||
windowLabel: 'Интерактивная копия ClickGui LoVisual',
|
||||
bind: 'Бинд',
|
||||
bindNone: 'нет',
|
||||
toggle: 'Включить {{name}}',
|
||||
settings: 'Настройки {{name}}',
|
||||
categories: 'Категории',
|
||||
theme: 'Тема {{name}}',
|
||||
noSettings: 'Без настроек',
|
||||
desc: {
|
||||
PvpCooldowns: 'Таймеры перезарядки щита, жемчуга и тотема',
|
||||
Predictions: 'Траектория полёта жемчуга и стрел',
|
||||
TargetESP: 'Анимированная метка на текущей цели',
|
||||
DamageTint: 'Цветная вспышка при получении урона',
|
||||
NoFriendDamage: 'Не даёт случайно ударить друга',
|
||||
CombatLog: 'Лог попаданий и урона в чате',
|
||||
TPSSync: 'Подстраивает таймеры под TPS сервера',
|
||||
ProjectilePuncher: 'Сбивает летящие в тебя снаряды',
|
||||
Trails: 'Светящийся шлейф за игроком',
|
||||
JumpCircles: 'Круги на земле при прыжке',
|
||||
HitBubbles: 'Пузыри с уроном при ударе',
|
||||
AmbientParticles: 'Мягкие частицы вокруг тебя',
|
||||
Wings: 'Анимированные крылья за спиной',
|
||||
ChinaHat: 'Конусная шляпа с градиентом',
|
||||
ShaderSky: 'Шейдерное небо с облаками',
|
||||
MotionBlur: 'Размытие в движении',
|
||||
PostFX: 'Bloom, виньетка и цветокоррекция',
|
||||
KillEffect: 'Эффект молнии при убийстве',
|
||||
ThirdPerson: 'Плавная камера от третьего лица',
|
||||
AutoTool: 'Сам берёт нужный инструмент',
|
||||
DeathCoords: 'Координаты смерти в чате',
|
||||
AutoRespawn: 'Возрождение без экрана смерти',
|
||||
ClickPearl: 'Жемчуг по кнопке мыши',
|
||||
LockSlot: 'Защищает слоты от выброса',
|
||||
UseTracker: 'Счётчик использования предметов',
|
||||
NoDelay: 'Убирает задержку прыжка',
|
||||
StreamerMode: 'Прячет личные данные на стриме',
|
||||
NameProtect: 'Заменяет твой ник на экране',
|
||||
HitSounds: 'Звук попадания на выбор',
|
||||
ItemScroller: 'Быстрое перекладывание предметов',
|
||||
MessageFilter: 'Фильтр спама в чате',
|
||||
Optimize: 'Лишние FPS без потери красоты',
|
||||
NoServerPack: 'Отказ от серверного ресурспака',
|
||||
BetterMinecraft: 'Мелкие улучшения интерфейса',
|
||||
},
|
||||
} as const
|
||||
70
frontend/src/features/clickgui/parts/Glyphs.tsx
Normal file
70
frontend/src/features/clickgui/parts/Glyphs.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { Cursor } from '../demo'
|
||||
import type { CategoryId } from './modules'
|
||||
|
||||
const PATHS: Record<CategoryId, string> = {
|
||||
combat: 'M14.5 3.5 20.5 3.5 20.5 9.5 10 20 7 20 4 17 4 14ZM6 13.5 10.5 18M3.5 20.5 6.5 17.5',
|
||||
visuals: 'M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z',
|
||||
player: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c.8-3.8 3.9-6 7.5-6s6.7 2.2 7.5 6',
|
||||
misc: 'M5 5h5v5H5ZM14 5h5v5h-5ZM5 14h5v5H5ZM14 14h5v5h-5Z',
|
||||
}
|
||||
|
||||
export function CategoryIcon({ id }: { id: CategoryId }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="lv-gui-icon" aria-hidden="true">
|
||||
<path d={PATHS[id]} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" className="lv-gui-chevron" data-open={open} aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogoMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="lv-gui-logo-mark" aria-hidden="true">
|
||||
<path d="M10 1.5 18.5 10 10 18.5 1.5 10Z" fill="var(--gui-accent)" />
|
||||
<path d="M10 5.5 14.5 10 10 14.5 5.5 10Z" fill="var(--gui-window-bg)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" className="lv-gui-icon" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The demo's fake mouse pointer; positioned in the window's own (untransformed) coordinates. */
|
||||
export function DemoCursor({ cursor }: { cursor: Cursor }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="lv-gui-cursor"
|
||||
data-visible={cursor.visible}
|
||||
data-pressed={cursor.pressed}
|
||||
style={{
|
||||
transform: `translate3d(${cursor.x}px, ${cursor.y}px, 0)`,
|
||||
transitionDuration: `${cursor.duration}ms, 200ms`,
|
||||
}}
|
||||
>
|
||||
<span className="lv-gui-cursor-ring" />
|
||||
<svg viewBox="0 0 20 24" width="20" height="24">
|
||||
<path
|
||||
d="M2 1.5v17.2l4.6-4.3 3 7 3.2-1.4-3-6.8h6.4Z"
|
||||
fill="#fff"
|
||||
stroke="#0b1012"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
frontend/src/features/clickgui/parts/SettingControls.tsx
Normal file
92
frontend/src/features/clickgui/parts/SettingControls.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { Dispatch } from 'react'
|
||||
import type { GuiAction, GuiState } from '../demo'
|
||||
import { type Setting, settingKey } from './modules'
|
||||
|
||||
const COLOR_CYCLE = ['var(--gui-accent)', '#ff6b9a', '#f2c14e', '#7cf29c', '#9d7bff']
|
||||
|
||||
type Props = { module: string; settings: Setting[]; state: GuiState; dispatch: Dispatch<GuiAction> }
|
||||
|
||||
/** Expanded settings of a module card: sliders, checkboxes and color dots like the in-game panel. */
|
||||
export function SettingControls({ module, settings, state, dispatch }: Props) {
|
||||
return (
|
||||
<div className="lv-gui-settings">
|
||||
{settings.map((s) => {
|
||||
const key = settingKey(module, s.name)
|
||||
const value = state.values[key] ?? s.value
|
||||
if (s.kind === 'slider') {
|
||||
return <Slider key={key} id={key} setting={s} value={Number(value)} dragging={state.dragging} dispatch={dispatch} />
|
||||
}
|
||||
if (s.kind === 'check') {
|
||||
const on = Boolean(value)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
data-demo={`check:${key}`}
|
||||
className="lv-gui-check-row"
|
||||
onClick={() => dispatch({ type: 'set', key, value: !on })}
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="lv-gui-check" data-on={on}>
|
||||
<svg viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path d="M2.5 6.2 5 8.6l4.5-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const current = String(value)
|
||||
const next = COLOR_CYCLE[(COLOR_CYCLE.indexOf(current) + 1) % COLOR_CYCLE.length]
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="lv-gui-check-row"
|
||||
aria-label={s.name}
|
||||
onClick={() => dispatch({ type: 'set', key, value: next })}
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="lv-gui-color" style={{ background: current, boxShadow: `0 0 10px ${current}` }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SliderProps = {
|
||||
id: string
|
||||
setting: Extract<Setting, { kind: 'slider' }>
|
||||
value: number
|
||||
dragging: boolean
|
||||
dispatch: Dispatch<GuiAction>
|
||||
}
|
||||
|
||||
function Slider({ id, setting, value, dragging, dispatch }: SliderProps) {
|
||||
const pct = ((value - setting.min) / (setting.max - setting.min)) * 100
|
||||
return (
|
||||
<label className="lv-gui-slider" data-dragging={dragging}>
|
||||
<span className="lv-gui-slider-head">
|
||||
<span>{setting.name}</span>
|
||||
<span className="lv-gui-slider-value">
|
||||
{Math.round(value)}
|
||||
{setting.unit ?? ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lv-gui-slider-track" data-demo={`slider:${id}`}>
|
||||
<span className="lv-gui-slider-fill" style={{ width: `${pct}%` }} />
|
||||
<span className="lv-gui-slider-knob" data-demo={`knob:${id}`} style={{ left: `${pct}%` }} />
|
||||
<input
|
||||
type="range"
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
value={value}
|
||||
aria-label={setting.name}
|
||||
onChange={(e) => dispatch({ type: 'set', key: id, value: Number(e.target.value) })}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
220
frontend/src/features/clickgui/parts/clickgui.css
Normal file
220
frontend/src/features/clickgui/parts/clickgui.css
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/* ClickGui replica. Colors come from themeToCssVars(); the typed @property registrations
|
||||
let a theme switch cross-fade (400ms) instead of snapping. */
|
||||
@property --gui-window-from { syntax: "<color>"; inherits: true; initial-value: #12191b; }
|
||||
@property --gui-window-to { syntax: "<color>"; inherits: true; initial-value: #12191b; }
|
||||
@property --gui-header-from { syntax: "<color>"; inherits: true; initial-value: #142226; }
|
||||
@property --gui-header-to { syntax: "<color>"; inherits: true; initial-value: #142226; }
|
||||
@property --gui-card-from { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
|
||||
@property --gui-card-to { syntax: "<color>"; inherits: true; initial-value: #43a7c8; }
|
||||
@property --gui-stroke-from { syntax: "<color>"; inherits: true; initial-value: #203030; }
|
||||
@property --gui-stroke-to { syntax: "<color>"; inherits: true; initial-value: #203030; }
|
||||
@property --gui-surface { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-surface-hover { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-card-off { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-text { syntax: "<color>"; inherits: true; initial-value: #fff; }
|
||||
@property --gui-text-muted { syntax: "<color>"; inherits: true; initial-value: #c6d2cd; }
|
||||
@property --gui-accent { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
|
||||
@property --gui-accent-soft { syntax: "<color>"; inherits: true; initial-value: #5cc8e7; }
|
||||
@property --gui-accent-glow { syntax: "<color>"; inherits: true; initial-value: transparent; }
|
||||
@property --gui-stroke-soft { syntax: "<color>"; inherits: true; initial-value: #fff; }
|
||||
|
||||
.lv-gui {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
height: 452px;
|
||||
container-type: inline-size;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--gui-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-card-from,
|
||||
--gui-card-to, --gui-stroke-from, --gui-stroke-to, --gui-surface, --gui-surface-hover, --gui-card-off, --gui-text,
|
||||
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft;
|
||||
transition-duration: 400ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
.lv-gui button { font: inherit; color: inherit; cursor: pointer; }
|
||||
.lv-gui-icon { width: 16px; height: 16px; flex: none; }
|
||||
|
||||
.lv-gui-bloom {
|
||||
position: absolute;
|
||||
inset: 8% 4% -2%;
|
||||
border-radius: 40px;
|
||||
background: radial-gradient(60% 60% at 50% 45%, var(--gui-accent-glow), transparent 70%);
|
||||
filter: blur(40px);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lv-gui-window {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(var(--gui-window-angle), var(--gui-window-from), var(--gui-window-to)) padding-box,
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent) 55%, transparent), var(--gui-stroke-from) 35%,
|
||||
var(--gui-stroke-to) 70%, color-mix(in srgb, var(--gui-accent) 30%, transparent)) border-box;
|
||||
box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.75), 0 0 0 1px rgb(0 0 0 / 0.35), 0 0 42px -6px var(--gui-accent-glow);
|
||||
backdrop-filter: blur(14px) saturate(1.2);
|
||||
}
|
||||
|
||||
.lv-gui-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
flex: none;
|
||||
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 25%, transparent);
|
||||
}
|
||||
.lv-gui-logo { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); font-weight: 700; font-size: 13px; letter-spacing: 0.02em; }
|
||||
.lv-gui-logo-mark { width: 16px; height: 16px; filter: drop-shadow(0 0 6px var(--gui-accent)); }
|
||||
.lv-gui-tabs { display: none; gap: 4px; margin-left: 10px; }
|
||||
.lv-gui-tab { position: relative; padding: 5px 10px; border-radius: 7px; color: var(--gui-text-muted); font-size: 12px; }
|
||||
.lv-gui-tab[data-active="true"] { color: var(--gui-text); background: color-mix(in srgb, var(--gui-accent) 14%, transparent); }
|
||||
.lv-gui-tab[data-active="true"]::after {
|
||||
content: ""; position: absolute; left: 10px; right: 10px; bottom: -8px; height: 2px; border-radius: 2px;
|
||||
background: var(--gui-accent); box-shadow: 0 0 10px var(--gui-accent);
|
||||
}
|
||||
.lv-gui-search {
|
||||
margin-left: auto; display: flex; align-items: center; justify-content: center; width: 30px; height: 26px;
|
||||
border-radius: 7px; color: var(--gui-text-muted); background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
|
||||
}
|
||||
|
||||
.lv-gui-body { display: flex; min-height: 0; flex: 1; }
|
||||
.lv-gui-cats {
|
||||
display: flex; flex-direction: column; gap: 3px; width: 46px; flex: none; padding: 10px 6px;
|
||||
border-right: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 14%, transparent);
|
||||
background: color-mix(in srgb, black 12%, transparent);
|
||||
}
|
||||
.lv-gui-cat {
|
||||
position: relative; display: flex; align-items: center; justify-content: center; gap: 9px; height: 32px;
|
||||
padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--gui-text-muted) !important;
|
||||
transition: background-color 200ms, color 200ms;
|
||||
}
|
||||
.lv-gui-cat:hover { background: var(--gui-surface-hover); }
|
||||
.lv-gui-cat[data-active="true"] {
|
||||
color: var(--gui-text) !important;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--gui-accent) 24%, transparent), transparent);
|
||||
}
|
||||
.lv-gui-cat[data-active="true"]::before {
|
||||
content: ""; position: absolute; left: -6px; top: 7px; bottom: 7px; width: 3px; border-radius: 0 3px 3px 0;
|
||||
background: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent);
|
||||
}
|
||||
.lv-gui-cat[data-active="true"] .lv-gui-icon { color: var(--gui-accent); filter: drop-shadow(0 0 5px var(--gui-accent)); }
|
||||
.lv-gui-cat-name, .lv-gui-cat-count { display: none; }
|
||||
.lv-gui-cat-count { margin-left: auto; font-family: var(--font-code); font-size: 10px; opacity: 0.6; }
|
||||
.lv-gui-theme-dots { margin-top: auto; display: grid; grid-template-columns: 1fr; justify-items: center; gap: 7px; padding-top: 10px; }
|
||||
.lv-gui-theme-dot {
|
||||
width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgb(255 255 255 / 0.25); background: var(--dot);
|
||||
box-shadow: inset 0 0 0 3px rgb(0 0 0 / 0.25), 0 0 0 0 var(--dot-accent); transition: box-shadow 250ms, transform 250ms;
|
||||
}
|
||||
.lv-gui-theme-dot::after { content: ""; display: block; margin: 4px; height: 6px; border-radius: 50%; background: var(--dot-accent); }
|
||||
.lv-gui-theme-dot[data-active="true"] { box-shadow: 0 0 0 2px var(--dot-accent), 0 0 12px var(--dot-accent); transform: scale(1.1); }
|
||||
|
||||
.lv-gui-main {
|
||||
position: relative; flex: 1; min-width: 0; overflow: hidden; padding: 10px;
|
||||
mask-image: linear-gradient(to bottom, #000 82%, transparent);
|
||||
}
|
||||
.lv-gui-grid { display: grid; grid-template-columns: 1fr; gap: 8px; align-items: start; animation: lv-gui-in 380ms ease both; }
|
||||
@keyframes lv-gui-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
|
||||
.lv-gui-card {
|
||||
position: relative; overflow: hidden; padding: 8px 9px 7px; border-radius: 6px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--gui-surface-hover) 70%, transparent), var(--gui-card-off)),
|
||||
rgb(8 12 14 / 0.55);
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent);
|
||||
box-shadow: 0 2px 9px rgb(0 0 0 / 0.28);
|
||||
transition: border-color 250ms, box-shadow 250ms, transform 200ms;
|
||||
}
|
||||
.lv-gui-card:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--gui-stroke-soft) 26%, transparent); }
|
||||
.lv-gui-card[data-on="true"] {
|
||||
background: linear-gradient(var(--gui-card-angle, 180deg), var(--gui-card-from), var(--gui-card-to)), rgb(8 12 14 / 0.45);
|
||||
border-color: color-mix(in srgb, var(--gui-accent) 40%, transparent);
|
||||
box-shadow: 0 2px 14px rgb(0 0 0 / 0.3), 0 0 18px -6px var(--gui-accent-glow);
|
||||
}
|
||||
.lv-gui-card-glow {
|
||||
position: absolute; left: -30px; top: -40px; width: 120px; height: 90px; border-radius: 50%;
|
||||
background: radial-gradient(closest-side, var(--gui-accent-soft), transparent); opacity: 0.12; pointer-events: none;
|
||||
transition: opacity 250ms;
|
||||
}
|
||||
.lv-gui-card[data-on="true"] .lv-gui-card-glow { opacity: 0.4; }
|
||||
.lv-gui-card-title { position: relative; margin: 0; font-family: var(--font-sans); font-weight: 600; font-size: 13px; color: var(--gui-text); opacity: 0.66; transition: opacity 250ms; }
|
||||
.lv-gui-card[data-on="true"] .lv-gui-card-title { opacity: 1; }
|
||||
.lv-gui-card-desc { position: relative; margin: 2px 0 0; font-size: 11px; color: var(--gui-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lv-gui-card-divider { height: 1px; margin: 7px -9px 6px; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent), transparent); }
|
||||
.lv-gui-card-foot { position: relative; display: flex; align-items: center; gap: 6px; }
|
||||
.lv-gui-bind { font-size: 10px; color: var(--gui-text-muted); }
|
||||
.lv-gui-bind span { font-family: var(--font-code); }
|
||||
.lv-gui-nosettings { display: none; }
|
||||
.lv-gui-expand {
|
||||
margin-left: auto; display: flex; width: 22px; height: 18px; align-items: center; justify-content: center;
|
||||
border: 0; border-radius: 5px; background: color-mix(in srgb, var(--gui-stroke-soft) 8%, transparent);
|
||||
}
|
||||
.lv-gui-expand:hover { background: var(--gui-surface-hover); }
|
||||
.lv-gui-nosettings + .lv-gui-toggle, .lv-gui-bind + .lv-gui-toggle { margin-left: auto; }
|
||||
.lv-gui-chevron { width: 12px; height: 12px; color: var(--gui-text-muted); transition: transform 250ms; }
|
||||
.lv-gui-chevron[data-open="true"] { transform: rotate(180deg); color: var(--gui-accent); }
|
||||
.lv-gui-toggle {
|
||||
position: relative; width: 28px; height: 15px; flex: none; padding: 0; border-radius: 99px;
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 30%, transparent); background: rgb(0 0 0 / 0.35);
|
||||
transition: background-color 250ms, box-shadow 250ms, border-color 250ms;
|
||||
}
|
||||
.lv-gui-toggle-knob {
|
||||
position: absolute; top: 2px; left: 2px; width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--gui-text-muted); transition: transform 250ms cubic-bezier(0.3, 1.4, 0.5, 1), background-color 250ms;
|
||||
}
|
||||
.lv-gui-toggle[aria-checked="true"] {
|
||||
background: var(--gui-accent); border-color: var(--gui-accent); box-shadow: 0 0 12px var(--gui-accent-glow), 0 0 4px var(--gui-accent);
|
||||
}
|
||||
.lv-gui-toggle[aria-checked="true"] .lv-gui-toggle-knob { transform: translateX(13px); background: #fff; }
|
||||
|
||||
.lv-gui-settings { position: relative; display: flex; flex-direction: column; gap: 7px; margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent); animation: lv-gui-in 260ms ease both; }
|
||||
.lv-gui-slider { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--gui-text-muted); }
|
||||
.lv-gui-slider-head { display: flex; justify-content: space-between; }
|
||||
.lv-gui-slider-value { font-family: var(--font-code); color: var(--gui-text); }
|
||||
.lv-gui-slider-track { position: relative; display: block; height: 4px; border-radius: 4px; background: rgb(0 0 0 / 0.4); }
|
||||
.lv-gui-slider-fill { position: absolute; inset: 0 auto 0 0; border-radius: 4px; background: linear-gradient(90deg, var(--gui-accent-soft), var(--gui-accent)); box-shadow: 0 0 8px var(--gui-accent-glow); }
|
||||
.lv-gui-slider-knob { position: absolute; top: -4px; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 45%, transparent), 0 0 10px var(--gui-accent); }
|
||||
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-fill { transition: width 900ms cubic-bezier(0.4, 0, 0.3, 1); }
|
||||
.lv-gui-slider[data-dragging="true"] .lv-gui-slider-knob { transition: left 900ms cubic-bezier(0.4, 0, 0.3, 1); }
|
||||
.lv-gui-slider input { position: absolute; inset: -8px 0; width: 100%; opacity: 0; cursor: pointer; margin: 0; }
|
||||
.lv-gui-check-row { display: flex; align-items: center; justify-content: space-between; padding: 0; border: 0; background: none; font-size: 11px; color: var(--gui-text-muted) !important; }
|
||||
.lv-gui-check { display: flex; width: 14px; height: 14px; border-radius: 4px; border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 40%, transparent); color: transparent; transition: all 200ms; }
|
||||
.lv-gui-check[data-on="true"] { background: var(--gui-accent); border-color: var(--gui-accent); color: #0b1012; box-shadow: 0 0 8px var(--gui-accent-glow); }
|
||||
.lv-gui-color { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgb(255 255 255 / 0.8); }
|
||||
|
||||
.lv-gui-cursor {
|
||||
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; opacity: 0;
|
||||
transition-property: transform, opacity; transition-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
|
||||
filter: drop-shadow(0 3px 5px rgb(0 0 0 / 0.5));
|
||||
}
|
||||
.lv-gui-cursor[data-visible="true"] { opacity: 1; }
|
||||
.lv-gui-cursor svg { position: relative; margin: -2px 0 0 -2px; transition: transform 120ms; }
|
||||
.lv-gui-cursor[data-pressed="true"] svg { transform: scale(0.86); }
|
||||
.lv-gui-cursor-ring {
|
||||
position: absolute; left: -14px; top: -14px; width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid var(--gui-accent); opacity: 0; transform: scale(0.4); transition: opacity 300ms, transform 300ms;
|
||||
}
|
||||
.lv-gui-cursor[data-pressed="true"] .lv-gui-cursor-ring { opacity: 0.9; transform: scale(1); box-shadow: 0 0 14px var(--gui-accent); }
|
||||
|
||||
@container (min-width: 440px) {
|
||||
.lv-gui-cats { width: 128px; }
|
||||
.lv-gui-cat { justify-content: flex-start; }
|
||||
.lv-gui-cat-name, .lv-gui-cat-count { display: inline; }
|
||||
.lv-gui-theme-dots { grid-template-columns: repeat(4, 1fr); }
|
||||
.lv-gui-grid { grid-template-columns: 1fr 1fr; }
|
||||
.lv-gui-nosettings { display: inline; margin-left: auto; opacity: 0.6; }
|
||||
}
|
||||
@container (min-width: 560px) {
|
||||
.lv-gui-tabs { display: flex; }
|
||||
}
|
||||
97
frontend/src/features/clickgui/parts/modules.ts
Normal file
97
frontend/src/features/clickgui/parts/modules.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// In-game catalogue shown by the ClickGui replica. Module, category and setting names are
|
||||
// the mod's own identifiers and stay untranslated; descriptions live in the clickgui i18n.
|
||||
|
||||
export type Setting =
|
||||
| { kind: 'slider'; name: string; min: number; max: number; value: number; unit?: string }
|
||||
| { kind: 'check'; name: string; value: boolean }
|
||||
| { kind: 'color'; name: string; value: string }
|
||||
|
||||
export type GuiModule = { name: ModuleName; on?: boolean; settings?: Setting[] }
|
||||
|
||||
export type CategoryId = 'combat' | 'visuals' | 'player' | 'misc'
|
||||
|
||||
export type Category = { id: CategoryId; name: string; modules: GuiModule[] }
|
||||
|
||||
export type ModuleName =
|
||||
| 'PvpCooldowns' | 'Predictions' | 'TargetESP' | 'DamageTint' | 'NoFriendDamage' | 'CombatLog' | 'TPSSync'
|
||||
| 'ProjectilePuncher' | 'Trails' | 'JumpCircles' | 'HitBubbles' | 'AmbientParticles' | 'Wings' | 'ChinaHat'
|
||||
| 'ShaderSky' | 'MotionBlur' | 'PostFX' | 'KillEffect' | 'ThirdPerson' | 'AutoTool' | 'DeathCoords'
|
||||
| 'AutoRespawn' | 'ClickPearl' | 'LockSlot' | 'UseTracker' | 'NoDelay' | 'StreamerMode' | 'NameProtect'
|
||||
| 'HitSounds' | 'ItemScroller' | 'MessageFilter' | 'Optimize' | 'NoServerPack' | 'BetterMinecraft'
|
||||
|
||||
const slider = (name: string, value: number, max = 100, unit?: string): Setting => ({
|
||||
kind: 'slider', name, min: 0, max, value, unit,
|
||||
})
|
||||
const check = (name: string, value: boolean): Setting => ({ kind: 'check', name, value })
|
||||
const color = (name: string, value: string): Setting => ({ kind: 'color', name, value })
|
||||
|
||||
export const CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 'combat',
|
||||
name: 'Combat',
|
||||
modules: [
|
||||
{ name: 'TargetESP', on: true, settings: [slider('Size', 60), check('Pulse', true), color('Color', 'var(--gui-accent)')] },
|
||||
{ name: 'PvpCooldowns', on: true },
|
||||
{ name: 'Predictions', settings: [slider('Ticks', 6, 20), check('Show path', true)] },
|
||||
{ name: 'DamageTint', on: true, settings: [slider('Strength', 40), color('Tint', '#ff5a6e')] },
|
||||
{ name: 'NoFriendDamage' },
|
||||
{ name: 'TPSSync' },
|
||||
{ name: 'ProjectilePuncher' },
|
||||
{ name: 'CombatLog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'visuals',
|
||||
name: 'Visuals',
|
||||
modules: [
|
||||
{ name: 'Trails', settings: [slider('Length', 45), check('Rainbow', false), color('Color', 'var(--gui-accent)')] },
|
||||
{ name: 'JumpCircles', on: true, settings: [slider('Radius', 70), color('Color', 'var(--gui-accent)')] },
|
||||
{ name: 'HitBubbles', on: true },
|
||||
{ name: 'AmbientParticles', on: true, settings: [slider('Count', 120, 150), check('Glow', true)] },
|
||||
{ name: 'Wings' },
|
||||
{ name: 'ChinaHat', settings: [check('Gradient', true), color('Color', '#f2c14e')] },
|
||||
{ name: 'ShaderSky', on: true },
|
||||
{ name: 'MotionBlur', settings: [slider('Strength', 30)] },
|
||||
{ name: 'PostFX', on: true, settings: [check('Bloom', true), slider('Vignette', 25)] },
|
||||
{ name: 'KillEffect' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'player',
|
||||
name: 'Player',
|
||||
modules: [
|
||||
{ name: 'ThirdPerson', on: true, settings: [slider('Distance', 4, 12, 'm')] },
|
||||
{ name: 'AutoTool', on: true },
|
||||
{ name: 'DeathCoords', on: true },
|
||||
{ name: 'AutoRespawn' },
|
||||
{ name: 'ClickPearl' },
|
||||
{ name: 'LockSlot' },
|
||||
{ name: 'UseTracker' },
|
||||
{ name: 'NoDelay' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'misc',
|
||||
name: 'Misc',
|
||||
modules: [
|
||||
{ name: 'StreamerMode', settings: [check('Hide coords', true), check('Hide IP', true)] },
|
||||
{ name: 'NameProtect', on: true },
|
||||
{ name: 'HitSounds', on: true, settings: [slider('Volume', 65)] },
|
||||
{ name: 'ItemScroller' },
|
||||
{ name: 'MessageFilter' },
|
||||
{ name: 'Optimize', on: true },
|
||||
{ name: 'NoServerPack' },
|
||||
{ name: 'BetterMinecraft' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** The ClickGui's top tab row — in-game labels. */
|
||||
export const TABS = ['Modules', 'Themes', 'Configs', 'HUD'] as const
|
||||
|
||||
/** Presets offered as quick dots in the category column (and cycled by the demo). */
|
||||
export const QUICK_THEMES = ['classic', 'nitro', 'sunset', 'aurora'] as const
|
||||
|
||||
export const settingKey = (module: string, setting: string) => `${module}.${setting}`
|
||||
|
||||
export const findModule = (name: string) => CATEGORIES.flatMap((c) => c.modules).find((m) => m.name === name)
|
||||
47
frontend/src/features/clickgui/tests/clickgui.test.tsx
Normal file
47
frontend/src/features/clickgui/tests/clickgui.test.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { ActiveThemeProvider } from '../../themes/useActiveTheme'
|
||||
import { ClickGuiWindow } from '../ClickGuiWindow'
|
||||
import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo'
|
||||
|
||||
const renderGui = () => render(<ActiveThemeProvider applyToPage={false}><ClickGuiWindow /></ActiveThemeProvider>)
|
||||
|
||||
test('reduced motion shows the finished demo frame with no cursor', () => {
|
||||
renderGui()
|
||||
expect(screen.getByRole('button', { name: 'Visuals' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('slider', { name: 'Length' })).toHaveValue('85')
|
||||
})
|
||||
|
||||
test('cards are interactive: switch categories, toggle and expand', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderGui()
|
||||
await user.click(screen.getByRole('button', { name: 'Misc' }))
|
||||
const toggle = screen.getByRole('switch', { name: 'Включить StreamerMode' })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'Настройки StreamerMode' }))
|
||||
const card = toggle.closest('article') as HTMLElement
|
||||
expect(within(card).getByRole('checkbox', { name: /Hide IP/ })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
test('the scripted demo toggles Trails and loops back to the start', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: !q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
renderGui()
|
||||
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
|
||||
act(() => vi.advanceTimersByTime(3200))
|
||||
expect(screen.getByRole('switch', { name: 'Включить Trails' })).toHaveAttribute('aria-checked', 'true')
|
||||
act(() => vi.advanceTimersByTime(LOOP_MS))
|
||||
expect(screen.getByRole('button', { name: 'Combat' })).toHaveAttribute('aria-pressed', 'true')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('demo timeline fits in the loop and the reducer resets', () => {
|
||||
expect(Math.max(...DEMO.map((s) => s.at))).toBeLessThan(LOOP_MS)
|
||||
const toggled = guiReducer(initialGuiState(), { type: 'toggle', module: 'Trails' })
|
||||
expect(toggled.enabled.Trails).toBe(true)
|
||||
expect(guiReducer(toggled, { type: 'reset' }).enabled.Trails).toBe(false)
|
||||
})
|
||||
41
frontend/src/features/clickgui/tests/themeVars.test.ts
Normal file
41
frontend/src/features/clickgui/tests/themeVars.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { cssAngle, themeToCssVars } from '../themeVars'
|
||||
|
||||
const preset = (id: string) => {
|
||||
const p = PRESETS.find((x) => x.id === id)
|
||||
if (!p) throw new Error(id)
|
||||
return p
|
||||
}
|
||||
|
||||
test('maps every palette slot of Classic exactly', () => {
|
||||
const vars = themeToCssVars(preset('classic'))
|
||||
expect(vars['--gui-window-bg']).toBe('rgba(18, 25, 27, 0.941)')
|
||||
expect(vars['--gui-header']).toBe('rgba(20, 34, 38, 0.941)')
|
||||
expect(vars['--gui-stroke']).toBe('rgba(32, 48, 48, 0.376)')
|
||||
expect(vars['--gui-card-off']).toBe('rgba(51, 64, 70, 0)')
|
||||
expect(vars['--gui-text']).toBe('rgba(255, 255, 255, 1)')
|
||||
expect(vars['--gui-text-muted']).toBe('rgba(198, 210, 205, 0.533)')
|
||||
expect(vars['--gui-accent']).toBe('rgba(92, 200, 231, 1)')
|
||||
expect(vars['--gui-accent-soft']).toBe('rgba(92, 200, 231, 0.502)')
|
||||
expect(vars['--gui-stroke-soft']).toBe('rgba(255, 255, 255, 0.376)')
|
||||
for (const key of ['--gui-surface', '--gui-surface-hover', '--gui-card-on']) expect(vars).toHaveProperty(key)
|
||||
})
|
||||
|
||||
test('disabled gradients fall back to the flat color, enabled ones become linear-gradients', () => {
|
||||
const classic = themeToCssVars(preset('classic'))
|
||||
expect(classic['--gui-window-gradient']).toBe('rgba(18, 25, 27, 0.941)')
|
||||
expect(classic['--gui-header-gradient']).toBe('linear-gradient(180deg, rgba(39, 52, 55, 0.941), rgba(19, 32, 36, 0.941))')
|
||||
|
||||
const nitro = themeToCssVars(preset('nitro'))
|
||||
expect(nitro['--gui-window-gradient']).toBe('linear-gradient(135deg, rgba(54, 32, 95, 1), rgba(11, 125, 255, 1))')
|
||||
expect(nitro['--gui-window-from']).toBe('rgba(54, 32, 95, 1)')
|
||||
expect(nitro['--gui-window-to']).toBe('rgba(11, 125, 255, 1)')
|
||||
expect(nitro['--gui-window-angle']).toBe('135deg')
|
||||
})
|
||||
|
||||
test('mod angles convert to CSS angles', () => {
|
||||
expect(cssAngle(90)).toBe(180)
|
||||
expect(cssAngle(0)).toBe(90)
|
||||
expect(cssAngle(300)).toBe(30)
|
||||
})
|
||||
57
frontend/src/features/clickgui/themeVars.ts
Normal file
57
frontend/src/features/clickgui/themeVars.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import { argbToCss, type GradientSpec, type ThemeEntry, withAlpha } from '../themes/types'
|
||||
|
||||
type CssVars = CSSProperties & Record<`--${string}`, string>
|
||||
|
||||
/**
|
||||
* The mod measures gradient angles from the +X axis with 90° pointing down (its header
|
||||
* gradient is "top-lit" at 90°); CSS measures from "to top" clockwise, so 90° there is
|
||||
* left→right. Mod angle + 90 gives the CSS angle.
|
||||
*/
|
||||
export const cssAngle = (modAngle: number) => (((modAngle + 90) % 360) + 360) % 360
|
||||
|
||||
/** `linear-gradient(...)` when the slot's gradient is enabled, else the flat fallback color. */
|
||||
export function gradientToCss(spec: GradientSpec, flat: string): string {
|
||||
if (!spec.enabled) return argbToCss(flat)
|
||||
return `linear-gradient(${cssAngle(spec.angle)}deg, ${argbToCss(spec.start)}, ${argbToCss(spec.end)})`
|
||||
}
|
||||
|
||||
const GRADIENT_SLOTS = [
|
||||
['window', 'windowGradient', 'windowBg'],
|
||||
['header', 'headerGradient', 'windowHeader'],
|
||||
['surface', 'surfaceGradient', 'surface'],
|
||||
['card', 'cardGradient', 'cardEnabled'],
|
||||
['stroke', 'strokeGradient', 'windowStroke'],
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Maps a mod theme to the CSS custom properties the ClickGui replica reads.
|
||||
* Besides the full `--gui-<slot>-gradient` strings it emits `-from`/`-to`/`-angle` parts:
|
||||
* those are registered as typed `@property`s so a theme switch cross-fades smoothly.
|
||||
*/
|
||||
export function themeToCssVars(entry: ThemeEntry): CssVars {
|
||||
const t = entry.theme
|
||||
const vars: CssVars = {
|
||||
'--gui-window-bg': argbToCss(t.windowBg),
|
||||
'--gui-header': argbToCss(t.windowHeader),
|
||||
'--gui-stroke': argbToCss(t.windowStroke),
|
||||
'--gui-surface': argbToCss(t.surface),
|
||||
'--gui-surface-hover': argbToCss(t.surfaceHover),
|
||||
'--gui-card-on': argbToCss(t.cardEnabled),
|
||||
'--gui-card-off': argbToCss(t.cardDisabled),
|
||||
'--gui-text': argbToCss(t.textPrimary),
|
||||
'--gui-text-muted': argbToCss(t.textMuted),
|
||||
'--gui-accent': argbToCss(t.accent),
|
||||
'--gui-accent-soft': argbToCss(t.accentSoft),
|
||||
'--gui-stroke-soft': argbToCss(t.strokeSoft),
|
||||
'--gui-accent-glow': withAlpha(t.accent, 0.45),
|
||||
}
|
||||
for (const [slot, key, flat] of GRADIENT_SLOTS) {
|
||||
const spec = entry[key]
|
||||
vars[`--gui-${slot}-gradient`] = gradientToCss(spec, t[flat])
|
||||
vars[`--gui-${slot}-from`] = argbToCss(spec.enabled ? spec.start : t[flat])
|
||||
vars[`--gui-${slot}-to`] = argbToCss(spec.enabled ? spec.end : t[flat])
|
||||
vars[`--gui-${slot}-angle`] = `${cssAngle(spec.angle)}deg`
|
||||
}
|
||||
return vars
|
||||
}
|
||||
27
frontend/src/features/download/DownloadButton.tsx
Normal file
27
frontend/src/features/download/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { DOWNLOAD_URL, formatSize } from './api'
|
||||
import '../landing/styles/landing.css'
|
||||
|
||||
type Props = {
|
||||
size?: 'md' | 'lg'
|
||||
/** Latest release tag (e.g. `0.1.1`); with `bytes` it renders a size hint under the label. */
|
||||
version?: string
|
||||
bytes?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The primary one-click download link. Kept query-free so the landing stays light. */
|
||||
export function DownloadButton({ size = 'md', version, bytes, className = '' }: Props) {
|
||||
const { t, i18n } = useTranslation('download')
|
||||
const hint = version && bytes ? t('versionHint', { version, size: formatSize(bytes, i18n.language) }) : null
|
||||
const pad = size === 'lg' ? 'px-7 py-4 text-lg' : ''
|
||||
|
||||
return (
|
||||
<span className={`inline-flex flex-col items-start gap-2 ${className}`}>
|
||||
<a href={DOWNLOAD_URL} download className={`lv-cta-primary ${pad}`}>
|
||||
{t('button')}
|
||||
</a>
|
||||
<span className="font-code text-xs text-frost">{hint ?? t('hint')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
43
frontend/src/features/download/ReleaseNotes.tsx
Normal file
43
frontend/src/features/download/ReleaseNotes.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import Markdown from 'react-markdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fetchReleases } from './api'
|
||||
|
||||
/**
|
||||
* Changelog feed. Markdown bodies are rendered by react-markdown, which does not
|
||||
* enable raw HTML — so a `<script>` or `onerror=` in a release body stays inert text.
|
||||
*/
|
||||
export function ReleaseNotes() {
|
||||
const { t, i18n } = useTranslation('download')
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['releases'],
|
||||
queryFn: fetchReleases,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (isError || !data || data.length === 0) {
|
||||
return (
|
||||
<p className="rounded-panel border border-white/10 bg-black/30 p-6 text-frost">{t('notes.empty')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
const date = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' })
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-8">
|
||||
{data.map((r) => (
|
||||
<li key={r.tag_name} className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 className="font-display text-xl font-bold text-snow">{r.name || r.tag_name}</h3>
|
||||
<time className="font-code text-xs text-frost" dateTime={r.published_at}>
|
||||
{date.format(new Date(r.published_at))}
|
||||
</time>
|
||||
</div>
|
||||
<div className="lv-markdown text-[0.95rem] leading-relaxed text-snow/85">
|
||||
<Markdown>{r.body}</Markdown>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
45
frontend/src/features/download/api.ts
Normal file
45
frontend/src/features/download/api.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
const DEFAULT_REPO = 'loki5512344/LoVisual-'
|
||||
|
||||
/** `owner/name` of the GitHub repository that publishes mod releases. */
|
||||
export const GITHUB_REPO: string = import.meta.env.VITE_GITHUB_REPO || DEFAULT_REPO
|
||||
|
||||
/**
|
||||
* One-click download of the newest build: GitHub redirects `releases/latest/download/<asset>`
|
||||
* straight to the file, so every release must carry an asset named exactly `lovisual.jar`.
|
||||
*/
|
||||
export const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/latest/download/lovisual.jar`
|
||||
|
||||
/** A published GitHub release, narrowed to the fields the download page renders. */
|
||||
export type Release = {
|
||||
tag_name: string
|
||||
name: string
|
||||
published_at: string
|
||||
body: string
|
||||
html_url: string
|
||||
assets: { name: string; size: number; download_count: number }[]
|
||||
}
|
||||
|
||||
/** Ten most recent releases from the public GitHub API (unauthenticated, ~60 req/h/IP). */
|
||||
export async function fetchReleases(): Promise<Release[]> {
|
||||
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=10`, {
|
||||
headers: { accept: 'application/vnd.github+json' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`)
|
||||
return (await res.json()) as Release[]
|
||||
}
|
||||
|
||||
/** Total bytes of `.jar` assets in a release — the download button's size hint. */
|
||||
export function releaseSize(release: Release): number {
|
||||
return release.assets.reduce((sum, a) => (a.name.endsWith('.jar') ? sum + a.size : sum), 0)
|
||||
}
|
||||
|
||||
/** Human size in megabytes, locale-aware (e.g. «4,2 МБ» / "4.2 MB"). */
|
||||
export function formatSize(bytes: number, locale: string): string {
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'unit',
|
||||
unit: 'megabyte',
|
||||
unitDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(mb)
|
||||
}
|
||||
29
frontend/src/features/download/i18n/en.ts
Normal file
29
frontend/src/features/download/i18n/en.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { downloadRu } from './ru'
|
||||
|
||||
export const downloadEn = {
|
||||
nav: 'Download',
|
||||
button: 'Download for free',
|
||||
hint: 'One .jar straight from GitHub',
|
||||
versionHint: 'v{version} · {size}',
|
||||
page: {
|
||||
title: 'Download LoVisual',
|
||||
lead: 'A free visuals mod for Minecraft 26.2 on Fabric. One file — no installers, no ads.',
|
||||
reqTitle: 'What you need',
|
||||
reqMc: 'Minecraft',
|
||||
reqLoader: 'Fabric Loader',
|
||||
reqApi: 'Fabric API',
|
||||
reqOptionalTitle: 'Optional, for maximum FPS',
|
||||
reqOptional: 'Sodium and Iris — the mod does not require them but plays nicely.',
|
||||
installTitle: 'Install in three steps',
|
||||
step1: 'Download lovisual.jar with the button above.',
|
||||
step2: 'Drop the file into your mods folder (create it if it is missing).',
|
||||
step3: 'Launch Minecraft with the Fabric profile — the mod is active right away.',
|
||||
},
|
||||
notes: {
|
||||
title: "What's new",
|
||||
allReleases: 'All releases on GitHub',
|
||||
empty: 'The changelog will appear once the first public release is out.',
|
||||
downloads: '{count} downloads',
|
||||
},
|
||||
} satisfies Translation<typeof downloadRu>
|
||||
26
frontend/src/features/download/i18n/ru.ts
Normal file
26
frontend/src/features/download/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const downloadRu = {
|
||||
nav: 'Скачать',
|
||||
button: 'Скачать бесплатно',
|
||||
hint: 'Один .jar прямо с GitHub',
|
||||
versionHint: 'v{version} · {size}',
|
||||
page: {
|
||||
title: 'Скачать LoVisual',
|
||||
lead: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric. Один файл — без установщиков и без рекламы.',
|
||||
reqTitle: 'Что понадобится',
|
||||
reqMc: 'Minecraft',
|
||||
reqLoader: 'Fabric Loader',
|
||||
reqApi: 'Fabric API',
|
||||
reqOptionalTitle: 'По желанию для максимального FPS',
|
||||
reqOptional: 'Sodium и Iris — мод не требует их, но дружит с ними.',
|
||||
installTitle: 'Установка в три шага',
|
||||
step1: 'Скачай lovisual.jar кнопкой выше.',
|
||||
step2: 'Положи файл в папку mods (создай её, если нет).',
|
||||
step3: 'Запусти Minecraft с профилем Fabric — мод активен сразу.',
|
||||
},
|
||||
notes: {
|
||||
title: 'Что нового',
|
||||
allReleases: 'Все релизы на GitHub',
|
||||
empty: 'Список изменений появится, когда выйдет первый публичный релиз.',
|
||||
downloads: '{count} загрузок',
|
||||
},
|
||||
} as const
|
||||
49
frontend/src/features/download/tests/download.test.tsx
Normal file
49
frontend/src/features/download/tests/download.test.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { DownloadButton } from '../DownloadButton'
|
||||
import { ReleaseNotes } from '../ReleaseNotes'
|
||||
|
||||
function withQuery(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
const release = (body: string) => ({
|
||||
tag_name: 'v0.1.1',
|
||||
name: 'v0.1.1',
|
||||
published_at: '2026-09-01T00:00:00Z',
|
||||
body,
|
||||
html_url: 'https://github.com/x/y/releases/tag/v0.1.1',
|
||||
assets: [{ name: 'lovisual.jar', size: 4_400_000, download_count: 12 }],
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
test('DownloadButton links straight to the latest lovisual.jar', () => {
|
||||
const { container } = withQuery(<DownloadButton />)
|
||||
const link = container.querySelector('a')
|
||||
expect(link?.getAttribute('href')).toBe(
|
||||
'https://github.com/loki5512344/LoVisual-/releases/latest/download/lovisual.jar',
|
||||
)
|
||||
expect(link?.hasAttribute('download')).toBe(true)
|
||||
})
|
||||
|
||||
test('ReleaseNotes renders Markdown but never raw HTML from a release body', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [release('# Заголовок\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>')],
|
||||
} as unknown as Response)
|
||||
|
||||
const { container } = withQuery(<ReleaseNotes />)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Заголовок' })).toBeInTheDocument()
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
test('ReleaseNotes shows the fallback when the releases fetch fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down'))
|
||||
withQuery(<ReleaseNotes />)
|
||||
expect(await screen.findByText(/первый публичный релиз/)).toBeInTheDocument()
|
||||
})
|
||||
66
frontend/src/features/landing/CommandHero.tsx
Normal file
66
frontend/src/features/landing/CommandHero.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
|
||||
import { ButtonLink } from '../../shared/ui/Button'
|
||||
import { useSession } from '../auth/session'
|
||||
|
||||
const COMMAND = '%config load 7KQ3M9XA'
|
||||
const TYPE_DELAY_MS = 60
|
||||
|
||||
|
||||
export function CommandHero() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { status } = useSession()
|
||||
|
||||
return (
|
||||
<section className="relative flex flex-col gap-6 pb-16 pt-20 sm:pt-28">
|
||||
<h2 className="text-[2rem] leading-[1.1] sm:text-[2.8rem]">
|
||||
{t('cloud.headlinePart1')}
|
||||
{' '}— <br className="hidden lg:inline" />
|
||||
<span className="text-ice">{t('cloud.headlinePart2')}</span>
|
||||
</h2>
|
||||
<p className="max-w-[60ch] text-lg text-frost">{t('cloud.subtitle')}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{status === 'authenticated' ? (
|
||||
<ButtonLink to="/configs">{t('cloud.ctaAuthenticated')}</ButtonLink>
|
||||
) : (
|
||||
<ButtonLink to="/register">{t('cloud.ctaAnonymous')}</ButtonLink>
|
||||
)}
|
||||
<ButtonLink to="/showcase" variant="quiet">
|
||||
{t('cloud.ctaShowcase')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
<ChatBar />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatBar() {
|
||||
const { t } = useTranslation('landing')
|
||||
const [typed, setTyped] = useState(() => (prefersReducedMotion() ? COMMAND.length : 0))
|
||||
const done = typed >= COMMAND.length
|
||||
|
||||
useEffect(() => {
|
||||
if (done) return
|
||||
const t = setInterval(() => setTyped((n) => Math.min(n + 1, COMMAND.length)), TYPE_DELAY_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [done])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t('cloud.chatAriaLabel')}
|
||||
className="mt-6 flex min-h-[4.5rem] w-full max-w-xl flex-col justify-end gap-1 rounded-md border border-white/5 bg-black/55 px-3 py-2 font-code text-base text-snow shadow-[0_0_40px_-12px_var(--page-accent-glow)]"
|
||||
>
|
||||
<p aria-hidden="true" className="whitespace-pre-wrap break-all">
|
||||
{COMMAND.slice(0, typed)}
|
||||
{done ? null : <span className="animate-pulse text-frost">▌</span>}
|
||||
</p>
|
||||
{done ? (
|
||||
<p aria-hidden="true" className="text-ice">
|
||||
{t('cloud.chatConfirmation')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/features/landing/HowItWorks.tsx
Normal file
56
frontend/src/features/landing/HowItWorks.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export function HowItWorks() {
|
||||
const { t } = useTranslation('landing')
|
||||
const slots = [
|
||||
{ name: t('howItWorks.slotNamePvp'), code: '7KQ3M9XA' },
|
||||
{ name: t('howItWorks.slotNameStream'), code: 'R2WD8HNC' },
|
||||
{ name: t('howItWorks.slotNameLazy'), code: 'L5TB4QZE' },
|
||||
{ name: null, code: null },
|
||||
]
|
||||
const linkSteps = [t('howItWorks.linkStep1'), t('howItWorks.linkStep2'), t('howItWorks.linkStep3')]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 pb-20">
|
||||
<section className="grid items-center gap-8 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.slotsTitle')}</h2>
|
||||
<p className="max-w-[60ch] text-frost">{t('howItWorks.slotsDesc')}</p>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-3" aria-label={t('howItWorks.slotsAriaLabel')}>
|
||||
{slots.map((slot, i) => (
|
||||
<li key={i} className="flex min-w-0 flex-col gap-1 rounded-panel border border-shoal bg-slate px-4 py-3">
|
||||
{slot.name ? (
|
||||
<>
|
||||
<span className="truncate text-snow">{slot.name}</span>
|
||||
<span className="font-code text-sm tracking-wider text-ice">{slot.code}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="italic text-frost">{t('howItWorks.emptySlot')}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-[1.5625rem] leading-tight">{t('howItWorks.linkTitle')}</h2>
|
||||
<ol className="flex max-w-[60ch] list-decimal flex-col gap-2 pl-6 marker:text-frost">
|
||||
{linkSteps.map((step) => (
|
||||
<li key={step} className="pl-1">
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<p className="max-w-[60ch] text-frost">
|
||||
{t('howItWorks.showcaseText')}{' '}
|
||||
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('howItWorks.showcaseLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
frontend/src/features/landing/effects/ParticleField.tsx
Normal file
138
frontend/src/features/landing/effects/ParticleField.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
|
||||
|
||||
const MAX_PARTICLES = 150
|
||||
const MAX_DPR = 2
|
||||
const SPRITE = 64
|
||||
|
||||
type Particle = { x: number; y: number; z: number; vx: number; vy: number; r: number; phase: number; speed: number }
|
||||
|
||||
/** A soft glowing dot tinted with `color`, drawn once and stamped for every particle (cheap "bloom"). */
|
||||
function makeSprite(color: string): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas')
|
||||
c.width = c.height = SPRITE
|
||||
const g = c.getContext('2d')
|
||||
if (!g) return c
|
||||
const h = SPRITE / 2
|
||||
const grad = g.createRadialGradient(h, h, 0, h, h, h)
|
||||
grad.addColorStop(0, 'rgba(255,255,255,1)')
|
||||
grad.addColorStop(0.12, color)
|
||||
grad.addColorStop(0.4, color.replace(/rgb\((.+)\)/, 'rgba($1, 0.25)'))
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
g.fillStyle = grad
|
||||
g.fillRect(0, 0, SPRITE, SPRITE)
|
||||
return c
|
||||
}
|
||||
|
||||
function spawn(w: number, h: number, anywhere: boolean): Particle {
|
||||
const z = 0.35 + Math.random() * 0.65
|
||||
return {
|
||||
x: Math.random() * w,
|
||||
y: anywhere ? Math.random() * h : h + 20,
|
||||
z,
|
||||
vx: (Math.random() - 0.5) * 0.12,
|
||||
vy: -(0.08 + Math.random() * 0.3) * z,
|
||||
r: (2 + Math.random() * 7) * z,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.6 + Math.random() * 1.4,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas of drifting glowing particles (the mod's AmbientParticles + bloom look).
|
||||
* `color` is an opaque `rgb(r, g, b)`. Pauses off-screen; a single static frame under reduced motion.
|
||||
*/
|
||||
export function ParticleField({ color, className = '' }: { color: string; className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const spriteRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
spriteRef.current = makeSprite(color)
|
||||
}, [color])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
const reduced = prefersReducedMotion()
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
|
||||
let w = 0
|
||||
let h = 0
|
||||
let particles: Particle[] = []
|
||||
let frame = 0
|
||||
let visible = true
|
||||
let time = 0
|
||||
const pointer = { x: 0, y: 0, tx: 0, ty: 0 }
|
||||
|
||||
const resize = () => {
|
||||
w = canvas.clientWidth
|
||||
h = canvas.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const count = Math.min(MAX_PARTICLES, Math.round((w * h) / 8000))
|
||||
particles = Array.from({ length: count }, () => spawn(w, h, true))
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
const sprite = spriteRef.current
|
||||
if (!sprite) return
|
||||
for (const p of particles) {
|
||||
const twinkle = 0.55 + 0.45 * Math.sin(time * 0.002 * p.speed + p.phase)
|
||||
const size = p.r * 4
|
||||
ctx.globalAlpha = twinkle * (0.35 + p.z * 0.65)
|
||||
ctx.drawImage(sprite, p.x + pointer.x * p.z - size / 2, p.y + pointer.y * p.z - size / 2, size, size)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(48, time ? now - time : 16) / 16
|
||||
time = now
|
||||
pointer.x += (pointer.tx - pointer.x) * 0.04
|
||||
pointer.y += (pointer.ty - pointer.y) * 0.04
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i]
|
||||
p.x += (p.vx + Math.sin(now * 0.0004 + p.phase) * 0.08) * dt
|
||||
p.y += p.vy * dt
|
||||
if (p.y < -30 || p.x < -30 || p.x > w + 30) particles[i] = spawn(w, h, false)
|
||||
}
|
||||
draw()
|
||||
frame = visible ? requestAnimationFrame(step) : 0
|
||||
}
|
||||
|
||||
resize()
|
||||
if (reduced) {
|
||||
draw()
|
||||
return
|
||||
}
|
||||
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
pointer.tx = (e.clientX / window.innerWidth - 0.5) * -30
|
||||
pointer.ty = (e.clientY / window.innerHeight - 0.5) * -20
|
||||
}
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize)
|
||||
ro?.observe(canvas)
|
||||
const io =
|
||||
typeof IntersectionObserver === 'undefined'
|
||||
? null
|
||||
: new IntersectionObserver(([entry]) => {
|
||||
visible = entry.isIntersecting && !document.hidden
|
||||
if (visible && !frame) frame = requestAnimationFrame(step)
|
||||
})
|
||||
io?.observe(canvas)
|
||||
window.addEventListener('pointermove', onPointer, { passive: true })
|
||||
frame = requestAnimationFrame(step)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
ro?.disconnect()
|
||||
io?.disconnect()
|
||||
window.removeEventListener('pointermove', onPointer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas ref={canvasRef} aria-hidden="true" data-testid="particle-field" className={`pointer-events-none absolute inset-0 h-full w-full ${className}`} />
|
||||
}
|
||||
47
frontend/src/features/landing/effects/Tilt.tsx
Normal file
47
frontend/src/features/landing/effects/Tilt.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { motion, useMotionValue, useSpring } from 'motion/react'
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
|
||||
|
||||
const MAX_DEG = 8
|
||||
const REST = { x: 4, y: -6 }
|
||||
const SPRING = { stiffness: 120, damping: 16, mass: 0.6 }
|
||||
|
||||
const clamp = (v: number) => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
|
||||
|
||||
/** Pointer-driven 3D tilt (max 8°) on a spring; rests at a slight angle for depth. */
|
||||
export function Tilt({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const rx = useMotionValue(REST.x)
|
||||
const ry = useMotionValue(REST.y)
|
||||
const rotateX = useSpring(rx, SPRING)
|
||||
const rotateY = useSpring(ry, SPRING)
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion()) return
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const el = ref.current
|
||||
if (!el || e.pointerType !== 'mouse') return
|
||||
const r = el.getBoundingClientRect()
|
||||
const nx = (e.clientX - (r.left + r.width / 2)) / (window.innerWidth / 2)
|
||||
const ny = (e.clientY - (r.top + r.height / 2)) / (window.innerHeight / 2)
|
||||
ry.set(clamp(nx * MAX_DEG))
|
||||
rx.set(clamp(-ny * MAX_DEG))
|
||||
}
|
||||
const onLeave = () => {
|
||||
rx.set(REST.x)
|
||||
ry.set(REST.y)
|
||||
}
|
||||
window.addEventListener('pointermove', onMove, { passive: true })
|
||||
document.documentElement.addEventListener('pointerleave', onLeave)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
document.documentElement.removeEventListener('pointerleave', onLeave)
|
||||
}
|
||||
}, [rx, ry])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} style={{ perspective: 1400 }}>
|
||||
<motion.div style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}>{children}</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
frontend/src/features/landing/effects/VoxelScene.tsx
Normal file
196
frontend/src/features/landing/effects/VoxelScene.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
|
||||
|
||||
/** Deterministic PRNG so the landscape looks the same on every visit. */
|
||||
function mulberry32(seed: number) {
|
||||
let a = seed
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
type Layer = {
|
||||
seed: number
|
||||
block: number
|
||||
/** Surface baseline as a fraction of height, and hill amplitude in blocks. */
|
||||
base: number
|
||||
amp: number
|
||||
top: string
|
||||
side: string
|
||||
deep: string
|
||||
trees: number
|
||||
torches: number
|
||||
/** Scroll parallax factor (fraction of scroll the layer lags behind). */
|
||||
lag: number
|
||||
}
|
||||
|
||||
const LAYERS: Layer[] = [
|
||||
{ seed: 7, block: 10, base: 0.5, amp: 9, top: '#4a3563', side: '#3a2a52', deep: '#302445', trees: 0, torches: 0, lag: 0.42 },
|
||||
{ seed: 21, block: 16, base: 0.6, amp: 6, top: '#35304f', side: '#241f38', deep: '#1c1830', trees: 0.08, torches: 0, lag: 0.28 },
|
||||
{ seed: 43, block: 26, base: 0.74, amp: 4, top: '#3d6b45', side: '#1c2a26', deep: '#141c1c', trees: 0.12, torches: 0.1, lag: 0.12 },
|
||||
]
|
||||
|
||||
function shade(hex: string, k: number): string {
|
||||
const n = Number.parseInt(hex.slice(1), 16)
|
||||
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v * k)))
|
||||
return `rgb(${f((n >> 16) & 255)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`
|
||||
}
|
||||
|
||||
function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, color: string, k: number) {
|
||||
ctx.fillStyle = shade(color, k)
|
||||
ctx.fillRect(x, y, s, s)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)'
|
||||
ctx.fillRect(x, y + s - 2, s, 2)
|
||||
ctx.fillRect(x + s - 1, y, 1, s)
|
||||
}
|
||||
|
||||
function drawLayer(ctx: CanvasRenderingContext2D, w: number, h: number, layer: Layer, accent: string) {
|
||||
const rnd = mulberry32(layer.seed)
|
||||
const s = layer.block
|
||||
const cols = Math.ceil(w / s) + 1
|
||||
const p1 = rnd() * 10
|
||||
const p2 = rnd() * 10
|
||||
const heights = Array.from({ length: cols }, (_, i) => {
|
||||
const n = Math.sin(i * 0.11 + p1) * 0.6 + Math.sin(i * 0.037 + p2) * 0.9 + Math.sin(i * 0.29 + p1 * 2) * 0.25
|
||||
return Math.round(h * layer.base / s - n * layer.amp)
|
||||
})
|
||||
const glow: [number, number][] = []
|
||||
heights.forEach((row, i) => {
|
||||
const x = i * s
|
||||
for (let r = row; r * s < h; r++) {
|
||||
const depth = r - row
|
||||
const color = depth === 0 ? layer.top : depth < 3 ? layer.side : layer.deep
|
||||
drawBlock(ctx, x, r * s, s, color, 0.9 + rnd() * 0.2)
|
||||
}
|
||||
// A thin accent rim on the sunward edge of the surface, like the mod's bloom catching the light.
|
||||
ctx.fillStyle = accent
|
||||
ctx.globalAlpha = 0.28
|
||||
ctx.fillRect(x, row * s, s, Math.max(1, s * 0.12))
|
||||
ctx.globalAlpha = 1
|
||||
const flat = heights[i + 1] === row && heights[i - 1] === row
|
||||
if (flat && rnd() < layer.trees) drawTree(ctx, x, row * s, s, rnd)
|
||||
else if (rnd() < layer.torches) {
|
||||
ctx.fillStyle = '#6b4a2b'
|
||||
ctx.fillRect(x + s * 0.42, row * s - s * 0.7, s * 0.16, s * 0.7)
|
||||
ctx.fillStyle = '#ffd27a'
|
||||
ctx.fillRect(x + s * 0.38, row * s - s * 0.9, s * 0.24, s * 0.24)
|
||||
glow.push([x + s / 2, row * s - s * 0.8])
|
||||
}
|
||||
})
|
||||
for (const [gx, gy] of glow) {
|
||||
const g = ctx.createRadialGradient(gx, gy, 0, gx, gy, s * 3)
|
||||
g.addColorStop(0, 'rgba(255, 190, 110, 0.55)')
|
||||
g.addColorStop(1, 'rgba(255, 190, 110, 0)')
|
||||
ctx.fillStyle = g
|
||||
ctx.fillRect(gx - s * 3, gy - s * 3, s * 6, s * 6)
|
||||
}
|
||||
}
|
||||
|
||||
function drawTree(ctx: CanvasRenderingContext2D, x: number, groundY: number, s: number, rnd: () => number) {
|
||||
const trunk = 2 + Math.floor(rnd() * 2)
|
||||
for (let i = 1; i <= trunk; i++) drawBlock(ctx, x, groundY - i * s, s, '#3b2a22', 0.9)
|
||||
const top = groundY - (trunk + 1) * s
|
||||
for (let dy = 0; dy < 2; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) drawBlock(ctx, x + dx * s, top - dy * s, s, '#1f3b2e', 0.85 + rnd() * 0.3)
|
||||
}
|
||||
drawBlock(ctx, x, top - 2 * s, s, '#244536', 1)
|
||||
}
|
||||
|
||||
function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) {
|
||||
const rnd = mulberry32(99)
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const size = rnd() < 0.85 ? 2 : 3
|
||||
ctx.fillStyle = `rgba(255, 245, 230, ${0.25 + rnd() * 0.6})`
|
||||
ctx.fillRect(Math.floor(rnd() * w), Math.floor(rnd() * h * 0.45), size, size)
|
||||
}
|
||||
// Square setting sun with a warm halo.
|
||||
const sx = w * 0.72
|
||||
const sy = h * 0.46
|
||||
const halo = ctx.createRadialGradient(sx, sy, 0, sx, sy, h * 0.4)
|
||||
halo.addColorStop(0, 'rgba(255, 170, 110, 0.55)')
|
||||
halo.addColorStop(1, 'rgba(255, 120, 90, 0)')
|
||||
ctx.fillStyle = halo
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
const sun = Math.max(48, h * 0.1)
|
||||
ctx.fillStyle = '#ffcf8a'
|
||||
ctx.fillRect(sx - sun / 2, sy - sun / 2, sun, sun)
|
||||
ctx.fillStyle = '#ffe7b8'
|
||||
ctx.fillRect(sx - sun / 4, sy - sun / 4, sun / 2, sun / 2)
|
||||
// Blocky clouds.
|
||||
for (let c = 0; c < 7; c++) {
|
||||
const cx = rnd() * w
|
||||
const cy = h * (0.12 + rnd() * 0.3)
|
||||
const u = 14
|
||||
ctx.fillStyle = `rgba(${200 + rnd() * 40}, ${120 + rnd() * 40}, ${170 + rnd() * 40}, 0.2)`
|
||||
const len = 4 + Math.floor(rnd() * 7)
|
||||
for (let i = 0; i < len; i++) ctx.fillRect(cx + i * u, cy + (i % 3 === 0 ? -u : 0), u, u * (i % 3 === 0 ? 2 : 1))
|
||||
}
|
||||
}
|
||||
|
||||
/** Procedural blocky dusk landscape with layered scroll parallax. Purely decorative. */
|
||||
export function VoxelScene({ accent, className = '' }: { accent: string; className?: string }) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const canvases = useRef<(HTMLCanvasElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
const paint = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const w = root.clientWidth
|
||||
const h = root.clientHeight * 1.25
|
||||
canvases.current.forEach((canvas, i) => {
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
if (i === 0) drawSky(ctx, w, h)
|
||||
else drawLayer(ctx, w, h, LAYERS[i - 1], accent)
|
||||
})
|
||||
}
|
||||
paint()
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(paint)
|
||||
ro?.observe(root)
|
||||
return () => ro?.disconnect()
|
||||
}, [accent])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion()) return
|
||||
let frame = 0
|
||||
const lags = [0.5, ...LAYERS.map((l) => l.lag)]
|
||||
const onScroll = () => {
|
||||
if (frame) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
const y = Math.min(window.scrollY, 1200)
|
||||
canvases.current.forEach((c, i) => {
|
||||
if (c) c.style.transform = `translate3d(0, ${(y * lags[i]).toFixed(1)}px, 0)`
|
||||
})
|
||||
})
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} aria-hidden="true" className={`lv-voxel pointer-events-none absolute inset-0 overflow-hidden ${className}`}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<canvas
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
canvases.current[i] = el
|
||||
}}
|
||||
className="absolute inset-x-0 top-0 h-[125%] w-full will-change-transform"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
frontend/src/features/landing/effects/useInView.ts
Normal file
26
frontend/src/features/landing/effects/useInView.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { type RefObject, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Tracks whether `ref` intersects the viewport. Without IntersectionObserver (old browsers,
|
||||
* jsdom) the element counts as visible so content never stays hidden.
|
||||
*/
|
||||
export function useInView<T extends Element>(ref: RefObject<T | null>, options: { once?: boolean; rootMargin?: string } = {}): boolean {
|
||||
const [inView, setInView] = useState(() => typeof IntersectionObserver === 'undefined')
|
||||
const { once = false, rootMargin = '0px' } = options
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setInView(entry.isIntersecting)
|
||||
if (entry.isIntersecting && once) observer.disconnect()
|
||||
},
|
||||
{ rootMargin },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [ref, once, rootMargin])
|
||||
|
||||
return inView
|
||||
}
|
||||
103
frontend/src/features/landing/i18n/en.ts
Normal file
103
frontend/src/features/landing/i18n/en.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { landingRu } from './ru'
|
||||
|
||||
export const landingEn = {
|
||||
hero: {
|
||||
badge: 'Minecraft 26.2 · Fabric',
|
||||
headlineLead: 'Visuals',
|
||||
headlineAccent: 'you can see',
|
||||
lead: 'A free legit mod for Minecraft 26.2: 52 visual modules, cloud configs and themes you build yourself.',
|
||||
download: 'Download for free',
|
||||
downloadHint: 'One .jar straight from GitHub',
|
||||
register: 'Create account',
|
||||
facts: {
|
||||
modules: 'modules',
|
||||
visuals: 'for visuals',
|
||||
themes: 'built-in themes',
|
||||
},
|
||||
tryIt: 'This is the real interface — hover and click around',
|
||||
},
|
||||
themeStrip: {
|
||||
eyebrow: 'Themes',
|
||||
title: 'Hundreds of shades. Yours is one.',
|
||||
lead: 'Every built-in theme, straight from the mod. Click one — the ClickGui and the whole site recolor on the fly.',
|
||||
cta: 'Build your own theme',
|
||||
current: 'Now',
|
||||
swatches: 'Built-in themes',
|
||||
palette: 'Theme palette',
|
||||
},
|
||||
cloud: {
|
||||
headlinePart1: 'Your LoVisual settings',
|
||||
headlinePart2: 'on any computer',
|
||||
subtitle: 'Four cloud slots, a code you can share with friends, and a showcase of ready-made configs.',
|
||||
ctaAuthenticated: 'My configs',
|
||||
ctaAnonymous: 'Create account',
|
||||
ctaShowcase: 'View showcase',
|
||||
chatConfirmation: '[LoVisual] Loaded config "pvp"',
|
||||
chatAriaLabel: 'Example: loading a config by code in the game chat',
|
||||
},
|
||||
howItWorks: {
|
||||
slotsTitle: 'Four slots in the cloud',
|
||||
slotsDesc:
|
||||
'Save up to four sets of settings and load any of them on another computer — every slot has a code a friend can load too.',
|
||||
slotsAriaLabel: 'Example slots',
|
||||
emptySlot: 'empty',
|
||||
slotNamePvp: 'pvp',
|
||||
slotNameStream: 'stream',
|
||||
slotNameLazy: 'lazy',
|
||||
linkTitle: 'Linking the game',
|
||||
linkStep1: 'Open the account tab in the mod — a code will appear there.',
|
||||
linkStep2: 'Enter the code on the "Link game" page.',
|
||||
linkStep3: "Done: the mod signs in to your account by itself, no password needed.",
|
||||
showcaseText: 'Any config from the showcase can be copied into a free slot with one button.',
|
||||
showcaseLink: 'Open showcase',
|
||||
},
|
||||
hud: {
|
||||
eyebrow: 'HUD',
|
||||
title: 'Arrange the widgets like in game',
|
||||
lead: 'The same widgets the mod draws: FPS, coordinates, module list, keys and ping. Drag them with the mouse or arrow keys.',
|
||||
caption: 'Drag it — just like in game.',
|
||||
reset: 'Reset',
|
||||
widgetFps: 'FPS',
|
||||
widgetCoords: 'Coordinates',
|
||||
widgetModules: 'Module list',
|
||||
widgetKeys: 'Key presses',
|
||||
widgetPing: 'Ping',
|
||||
unitMs: 'ms',
|
||||
dragAria: 'Draggable HUD widget "{{name}}". Arrow keys move it.',
|
||||
},
|
||||
wall: {
|
||||
eyebrow: 'Modules',
|
||||
title: '88 modules. 52 are about looks.',
|
||||
lead: 'The whole category wall. Hover a visuals module — I\'ll tell you what it does.',
|
||||
columnsAria: 'Module categories',
|
||||
},
|
||||
showcase: {
|
||||
eyebrow: 'Showcase',
|
||||
title: 'Ready-made configs from the community',
|
||||
lead: 'The most popular showcase configs. Copy any of them into your slot with one button.',
|
||||
cta: 'Open showcase',
|
||||
example: 'Example',
|
||||
by: 'by {{author}}',
|
||||
downloads: '{{count}} downloads',
|
||||
},
|
||||
faq: {
|
||||
eyebrow: 'FAQ',
|
||||
title: 'Frequently asked',
|
||||
q1: 'Is it really free?',
|
||||
a1: 'Yes. The mod is free and open source: no subscriptions, no pay-to-unlock features.',
|
||||
q2: 'Which Minecraft versions are supported?',
|
||||
a2: 'Minecraft 26.2 on the Fabric loader.',
|
||||
q3: 'Do I need an account?',
|
||||
a3: 'Not for the mod itself. An account is only needed for cloud slots and the config showcase.',
|
||||
q4: 'Is it legit? Will I get banned?',
|
||||
a4: 'LoVisual is a visuals mod: it reads nothing from memory and sends nothing to the server. Your admin decides.',
|
||||
q5: 'How do I move settings to another computer?',
|
||||
a5: 'Save a config to a cloud slot and load it by code on any other PC.',
|
||||
downloadTitle: 'Ready to try it?',
|
||||
downloadLead: 'One .jar straight from GitHub — just drop it into your mods folder.',
|
||||
downloadCta: 'Download for free',
|
||||
downloadNote: 'Minecraft 26.2 · Fabric · the latest release',
|
||||
releaseLink: 'Releases and versions',
|
||||
},
|
||||
} satisfies Translation<typeof landingRu>
|
||||
100
frontend/src/features/landing/i18n/ru.ts
Normal file
100
frontend/src/features/landing/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
export const landingRu = {
|
||||
hero: {
|
||||
badge: 'Minecraft 26.2 · Fabric',
|
||||
headlineLead: 'Визуалы,',
|
||||
headlineAccent: 'которые видно',
|
||||
lead: 'Бесплатный легит-мод для Minecraft 26.2: 52 визуальных модуля, облачные конфиги и темы, которые ты собираешь сам.',
|
||||
download: 'Скачать бесплатно',
|
||||
downloadHint: 'Один .jar прямо с GitHub',
|
||||
register: 'Создать аккаунт',
|
||||
facts: {
|
||||
modules: 'модулей',
|
||||
visuals: 'про визуал',
|
||||
themes: 'встроенных тем',
|
||||
},
|
||||
tryIt: 'Это настоящий интерфейс — наведи и покликай',
|
||||
},
|
||||
themeStrip: {
|
||||
eyebrow: 'Темы',
|
||||
title: 'Сотни оттенков. Твой — один.',
|
||||
lead: 'Все встроенные темы прямо из мода. Нажми на любую — ClickGui и весь сайт перекрасятся на лету.',
|
||||
cta: 'Собрать свою тему',
|
||||
current: 'Сейчас',
|
||||
swatches: 'Встроенные темы',
|
||||
palette: 'Палитра темы',
|
||||
},
|
||||
cloud: {
|
||||
headlinePart1: 'Твои настройки LoVisual',
|
||||
headlinePart2: 'на любом компьютере',
|
||||
subtitle: 'Четыре облачных слота, код, которым делишься с друзьями, и витрина готовых конфигов.',
|
||||
ctaAuthenticated: 'Мои конфиги',
|
||||
ctaAnonymous: 'Создать аккаунт',
|
||||
ctaShowcase: 'Смотреть витрину',
|
||||
chatConfirmation: '[LoVisual] Конфиг «pvp» загружен',
|
||||
chatAriaLabel: 'Пример: загрузка конфига по коду в чате игры',
|
||||
},
|
||||
howItWorks: {
|
||||
slotsTitle: 'Четыре слота в облаке',
|
||||
slotsDesc:
|
||||
'Сохрани до четырёх наборов настроек и загружай любой из них на другом компьютере — у каждого слота есть код, по которому его может загрузить и друг.',
|
||||
slotsAriaLabel: 'Пример слотов',
|
||||
emptySlot: 'пусто',
|
||||
slotNamePvp: 'pvp',
|
||||
slotNameStream: 'стрим',
|
||||
slotNameLazy: 'ленивый',
|
||||
linkTitle: 'Привязка игры',
|
||||
linkStep1: 'В моде открой вкладку аккаунта — там появится код.',
|
||||
linkStep2: 'Введи код на странице «Привязать игру».',
|
||||
linkStep3: 'Готово: мод входит в аккаунт сам, пароль ему не нужен.',
|
||||
showcaseText: 'Любой конфиг из витрины можно скопировать в свободный слот одной кнопкой.',
|
||||
showcaseLink: 'Открыть витрину',
|
||||
},
|
||||
hud: {
|
||||
eyebrow: 'HUD',
|
||||
title: 'Расставь элементы как в игре',
|
||||
lead: 'Те же виджеты, что рисует мод: FPS, координаты, список модулей, клавиши и пинг. Таскай их мышкой или стрелками.',
|
||||
caption: 'Перетащи — так же, как в игре.',
|
||||
reset: 'Сбросить',
|
||||
widgetFps: 'FPS',
|
||||
widgetCoords: 'Координаты',
|
||||
widgetModules: 'Список модулей',
|
||||
widgetKeys: 'Нажатия клавиш',
|
||||
widgetPing: 'Пинг',
|
||||
unitMs: 'мс',
|
||||
dragAria: 'Перетаскиваемый HUD-виджет «{{name}}». Стрелки — двигать.',
|
||||
},
|
||||
wall: {
|
||||
eyebrow: 'Модули',
|
||||
title: '88 модулей. 52 — про красоту.',
|
||||
lead: 'Вся витрина категорий. Наведи на визуальный модуль — расскажу, что он делает.',
|
||||
columnsAria: 'Категории модулей',
|
||||
},
|
||||
showcase: {
|
||||
eyebrow: 'Витрина',
|
||||
title: 'Готовые конфиги от сообщества',
|
||||
lead: 'Самые популярные конфиги витрины. Любой можно скопировать в свой слот одной кнопкой.',
|
||||
cta: 'Открыть витрину',
|
||||
example: 'Пример',
|
||||
by: 'от {{author}}',
|
||||
downloads: '{{count}} загрузок',
|
||||
},
|
||||
faq: {
|
||||
eyebrow: 'Вопросы',
|
||||
title: 'Частые вопросы',
|
||||
q1: 'Это правда бесплатно?',
|
||||
a1: 'Да. Мод бесплатный и с открытым кодом: без подписок и без доната за функции.',
|
||||
q2: 'Какие версии Minecraft поддерживаются?',
|
||||
a2: 'Minecraft 26.2 на загрузчике Fabric.',
|
||||
q3: 'Нужен ли аккаунт?',
|
||||
a3: 'Для самого мода — нет. Аккаунт нужен только для облачных слотов и витрины конфигов.',
|
||||
q4: 'Это легитно? Забанят ли?',
|
||||
a4: 'LoVisual — визуальный мод: он ничего не читает из памяти и не отправляет на сервер. Решает админ сервера.',
|
||||
q5: 'Как перенести настройки на другой компьютер?',
|
||||
a5: 'Сохрани конфиг в облачный слот и загрузи его по коду на любом другом ПК.',
|
||||
downloadTitle: 'Готов попробовать?',
|
||||
downloadLead: 'Один .jar прямо с GitHub — просто положи его в папку mods.',
|
||||
downloadCta: 'Скачать бесплатно',
|
||||
downloadNote: 'Minecraft 26.2 · Fabric · самый свежий релиз',
|
||||
releaseLink: 'Список релизов и версий',
|
||||
},
|
||||
} as const
|
||||
47
frontend/src/features/landing/sections/FaqDownload.tsx
Normal file
47
frontend/src/features/landing/sections/FaqDownload.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { DOWNLOAD_URL, GITHUB_REPO } from '../../download/api'
|
||||
import '../styles/landing.css'
|
||||
|
||||
const KEYS = ['1', '2', '3', '4', '5'] as const
|
||||
|
||||
export function FaqDownload() {
|
||||
const { t } = useTranslation('landing')
|
||||
const items = KEYS.map((k) => ({ q: t(`faq.q${k}`), a: t(`faq.a${k}`) }))
|
||||
|
||||
return (
|
||||
<section aria-labelledby="faq-title" className="grid gap-12 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('faq.eyebrow')}</span>
|
||||
<h2 id="faq-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('faq.title')}</h2>
|
||||
<div className="flex flex-col divide-y divide-white/10 border-y border-white/10">
|
||||
{items.map((it) => (
|
||||
<details key={it.q} className="lv-faq-item group py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-base font-semibold text-snow">
|
||||
{it.q}
|
||||
<span aria-hidden="true" className="lv-faq-marker" />
|
||||
</summary>
|
||||
<p className="mt-2 max-w-[52ch] text-sm leading-relaxed text-frost">{it.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lv-download-band flex min-w-0 flex-col items-start justify-center gap-5 rounded-panel p-8 sm:p-10">
|
||||
<h3 className="text-[1.75rem] leading-tight sm:text-[2.1rem]">{t('faq.downloadTitle')}</h3>
|
||||
<p className="max-w-[42ch] text-lg text-frost">{t('faq.downloadLead')}</p>
|
||||
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
|
||||
{t('faq.downloadCta')}
|
||||
</a>
|
||||
<p className="font-code text-xs text-frost">{t('faq.downloadNote')}</p>
|
||||
<a
|
||||
href={`https://github.com/${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-ice underline-offset-4 hover:underline"
|
||||
>
|
||||
{t('faq.releaseLink')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
109
frontend/src/features/landing/sections/Hero.tsx
Normal file
109
frontend/src/features/landing/sections/Hero.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { MotionConfig, motion } from 'motion/react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ButtonLink } from '../../../shared/ui/Button'
|
||||
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
|
||||
import { DOWNLOAD_URL } from '../../download/api'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { parseArgb } from '../../themes/types'
|
||||
import { useActiveTheme } from '../../themes/useActiveTheme'
|
||||
import { ParticleField } from '../effects/ParticleField'
|
||||
import { Tilt } from '../effects/Tilt'
|
||||
import { useInView } from '../effects/useInView'
|
||||
import { VoxelScene } from '../effects/VoxelScene'
|
||||
import '../styles/landing.css'
|
||||
|
||||
const rise = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 24 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] as const },
|
||||
})
|
||||
|
||||
function DownloadIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="size-5" aria-hidden="true">
|
||||
<path d="M10 3v10m0 0-4-4m4 4 4-4M4 16h12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { theme } = useActiveTheme()
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const inView = useInView(sectionRef)
|
||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||
const accent = `rgb(${r}, ${g}, ${b})`
|
||||
const facts = [
|
||||
{ n: '88', label: t('hero.facts.modules') },
|
||||
{ n: '52', label: t('hero.facts.visuals') },
|
||||
{ n: String(PRESETS.length), label: t('hero.facts.themes') },
|
||||
]
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<section
|
||||
ref={sectionRef}
|
||||
aria-labelledby="hero-title"
|
||||
className="lv-bleed relative isolate flex min-h-[calc(100svh-3.75rem)] items-center overflow-hidden"
|
||||
>
|
||||
<VoxelScene accent={accent} />
|
||||
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
|
||||
<ParticleField color={accent} />
|
||||
|
||||
<div className="relative mx-auto grid w-full max-w-[1200px] items-center gap-12 px-4 pb-20 pt-12 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.12fr)] lg:gap-8 lg:py-16">
|
||||
<div className="flex min-w-0 flex-col items-start gap-6">
|
||||
<motion.span
|
||||
{...rise(0)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1 font-code text-xs text-frost backdrop-blur"
|
||||
>
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hero.badge')}
|
||||
</motion.span>
|
||||
<motion.h1 {...rise(0.08)} id="hero-title" className="lv-hero-title font-display font-bold">
|
||||
{t('hero.headlineLead')} <span className="lv-hero-accent">{t('hero.headlineAccent')}</span>
|
||||
</motion.h1>
|
||||
<motion.p {...rise(0.16)} className="max-w-[34rem] text-lg leading-relaxed text-snow/80">
|
||||
{t('hero.lead')}
|
||||
</motion.p>
|
||||
<motion.div {...rise(0.24)} className="flex flex-wrap items-center gap-3">
|
||||
<a href={DOWNLOAD_URL} download className="lv-cta-primary">
|
||||
<DownloadIcon />
|
||||
{t('hero.download')}
|
||||
</a>
|
||||
<ButtonLink to="/register" variant="quiet" className="bg-black/25 px-5 py-3 text-base backdrop-blur">
|
||||
{t('hero.register')}
|
||||
</ButtonLink>
|
||||
</motion.div>
|
||||
<motion.p {...rise(0.3)} className="-mt-2 font-code text-xs text-frost">
|
||||
{t('hero.downloadHint')}
|
||||
</motion.p>
|
||||
<motion.dl {...rise(0.36)} className="mt-2 flex flex-wrap gap-x-8 gap-y-3">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label} className="flex flex-col">
|
||||
<dt className="order-2 text-sm text-frost">{f.label}</dt>
|
||||
<dd className="lv-fact font-display text-3xl font-bold text-snow">{f.n}</dd>
|
||||
</div>
|
||||
))}
|
||||
</motion.dl>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.94, y: 30 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.9, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="flex min-w-0 flex-col items-center gap-4"
|
||||
>
|
||||
<Tilt className="w-full max-w-[660px]">
|
||||
<ClickGuiWindow paused={!inView} />
|
||||
</Tilt>
|
||||
<p className="flex items-center gap-2 text-sm text-frost">
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hero.tryIt')}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
210
frontend/src/features/landing/sections/HudPlayground.tsx
Normal file
210
frontend/src/features/landing/sections/HudPlayground.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { themeToCssVars } from '../../clickgui/themeVars'
|
||||
import { parseArgb } from '../../themes/types'
|
||||
import { useActiveTheme } from '../../themes/useActiveTheme'
|
||||
import { VoxelScene } from '../effects/VoxelScene'
|
||||
import '../styles/landing.css'
|
||||
|
||||
const STEP = 8
|
||||
/** Fallback playfield size, used before the box is measured (and under jsdom). */
|
||||
const FIELD = { w: 680, h: 380 }
|
||||
const WIDGET = { w: 150, h: 56 }
|
||||
|
||||
type Pos = { x: number; y: number }
|
||||
/** Real draggable HUD elements from the mod: features/gui/hud/draggable/impl. */
|
||||
type WidgetId = 'fps' | 'coords' | 'modules' | 'keys' | 'ping'
|
||||
|
||||
const START: Record<WidgetId, Pos> = {
|
||||
fps: { x: 20, y: 20 },
|
||||
coords: { x: 20, y: 310 },
|
||||
modules: { x: FIELD.w - 190, y: 20 },
|
||||
ping: { x: FIELD.w - 170, y: 150 },
|
||||
keys: { x: FIELD.w - 140, y: FIELD.h - 120 },
|
||||
}
|
||||
|
||||
const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max))
|
||||
|
||||
/** Enabled modules as they would appear in the mod's ModuleList panel. */
|
||||
const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky']
|
||||
|
||||
/** Widgets read the accent through --gui-accent so it cross-fades with the active theme. */
|
||||
const ACCENT = 'var(--gui-accent)'
|
||||
|
||||
function WidgetContent({ id, accent, unitMs }: { id: WidgetId; accent: string; unitMs: string }) {
|
||||
switch (id) {
|
||||
case 'fps':
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="font-code text-2xl leading-none font-bold" style={{ color: accent }}>
|
||||
240
|
||||
</span>
|
||||
<span className="text-[0.6rem] tracking-[0.15em] text-frost uppercase">FPS</span>
|
||||
</div>
|
||||
)
|
||||
case 'coords':
|
||||
return (
|
||||
<div className="font-code text-sm leading-none text-snow">
|
||||
<span style={{ color: accent }}>X</span> 128 <span style={{ color: accent }}>Y</span> 74 {' '}
|
||||
<span style={{ color: accent }}>Z</span> -204
|
||||
</div>
|
||||
)
|
||||
case 'modules':
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{MODULES_ON.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="rounded-[5px] border-r-2 bg-black/25 px-2 py-[3px] font-code text-[0.7rem] leading-none text-snow"
|
||||
style={{ borderRightColor: accent }}
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'keys': {
|
||||
const cap = (k: string, pressed = false) => (
|
||||
<span
|
||||
key={k}
|
||||
className="grid size-7 place-items-center rounded-[6px] border bg-white/5 font-code text-xs leading-none"
|
||||
style={{
|
||||
borderColor: pressed ? accent : 'rgb(255 255 255 / 0.12)',
|
||||
color: pressed ? accent : undefined,
|
||||
background: pressed ? `color-mix(in srgb, ${accent} 18%, transparent)` : undefined,
|
||||
}}
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{cap('W', true)}
|
||||
<div className="flex gap-1">
|
||||
{cap('A')}
|
||||
{cap('S', true)}
|
||||
{cap('D')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'ping':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-code text-sm leading-none text-snow">
|
||||
32 <span className="text-frost">{unitMs}</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className="flex items-end gap-[3px]">
|
||||
{[6, 10, 14, 18].map((h, i) => (
|
||||
<span
|
||||
key={h}
|
||||
className="w-[4px] rounded-sm"
|
||||
style={{ height: h, background: i < 2 ? accent : 'rgb(255 255 255 / 0.18)' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function HudPlayground() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { theme } = useActiveTheme()
|
||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null)
|
||||
const [pos, setPos] = useState<Record<WidgetId, Pos>>(START)
|
||||
|
||||
const bounds = (el: HTMLElement) => {
|
||||
const field = fieldRef.current
|
||||
const w = field?.clientWidth || FIELD.w
|
||||
const h = field?.clientHeight || FIELD.h
|
||||
return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) }
|
||||
}
|
||||
|
||||
const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => {
|
||||
const { maxX, maxY } = bounds(el)
|
||||
setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } }))
|
||||
}
|
||||
|
||||
const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
const deltas: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0], ArrowUp: [0, -STEP], ArrowDown: [0, STEP],
|
||||
}
|
||||
const d = deltas[e.key]
|
||||
if (!d) return
|
||||
e.preventDefault()
|
||||
nudge(id, d[0], d[1], e.currentTarget)
|
||||
}
|
||||
|
||||
const onPointerDown = (id: WidgetId) => (e: React.PointerEvent<HTMLElement>) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
drag.current = { id, px: e.clientX, py: e.clientY, ox: pos[id].x, oy: pos[id].y }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {
|
||||
const d = drag.current
|
||||
if (!d) return
|
||||
const { maxX, maxY } = bounds(e.currentTarget)
|
||||
setPos((prev) => ({ ...prev, [d.id]: { x: clamp(d.ox + e.clientX - d.px, maxX), y: clamp(d.oy + e.clientY - d.py, maxY) } }))
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
drag.current = null
|
||||
}
|
||||
|
||||
const widgets: { id: WidgetId; label: string }[] = [
|
||||
{ id: 'fps', label: t('hud.widgetFps') },
|
||||
{ id: 'coords', label: t('hud.widgetCoords') },
|
||||
{ id: 'modules', label: t('hud.widgetModules') },
|
||||
{ id: 'keys', label: t('hud.widgetKeys') },
|
||||
{ id: 'ping', label: t('hud.widgetPing') },
|
||||
]
|
||||
|
||||
return (
|
||||
<section aria-labelledby="hud-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-14">
|
||||
<div className="flex min-w-0 flex-col items-start gap-4">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('hud.eyebrow')}</span>
|
||||
<h2 id="hud-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('hud.title')}</h2>
|
||||
<p className="max-w-[42ch] text-lg text-frost">{t('hud.lead')}</p>
|
||||
<Button variant="quiet" className="mt-2" onClick={() => setPos(START)}>
|
||||
{t('hud.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
ref={fieldRef}
|
||||
style={themeToCssVars(theme)}
|
||||
className="lv-hud-field relative isolate overflow-hidden rounded-panel border border-white/10"
|
||||
>
|
||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} />
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/25" />
|
||||
{widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={w.label}
|
||||
aria-label={t('hud.dragAria', { name: w.label })}
|
||||
onKeyDown={onKeyDown(w.id)}
|
||||
onPointerDown={onPointerDown(w.id)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
||||
className="lv-hud-widget absolute touch-none select-none"
|
||||
>
|
||||
<WidgetContent id={w.id} accent={ACCENT} unitMs={t('hud.unitMs')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-frost">
|
||||
<span className="lv-live-dot" aria-hidden="true" />
|
||||
{t('hud.caption')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
45
frontend/src/features/landing/sections/ModuleWall.tsx
Normal file
45
frontend/src/features/landing/sections/ModuleWall.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { CATEGORIES } from '../../clickgui/parts/modules'
|
||||
import '../styles/landing.css'
|
||||
|
||||
/** Each column scrolls at its own pace; the list is duplicated so the loop is seamless. */
|
||||
const SPEED_MS = [42, 30, 50, 36]
|
||||
|
||||
export function ModuleWall() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { t: tc } = useTranslation('clickgui')
|
||||
|
||||
return (
|
||||
<section aria-labelledby="wall-title" className="grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-14">
|
||||
<div className="flex min-w-0 flex-col items-start gap-4">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('wall.eyebrow')}</span>
|
||||
<h2 id="wall-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('wall.title')}</h2>
|
||||
<p className="max-w-[42ch] text-lg text-frost">{t('wall.lead')}</p>
|
||||
</div>
|
||||
|
||||
<div role="list" aria-label={t('wall.columnsAria')} className="lv-wall grid min-w-0 grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{CATEGORIES.map((cat, i) => (
|
||||
<div key={cat.id} role="listitem" className="lv-wall-col flex min-w-0 flex-col gap-2">
|
||||
<h3 className="font-code text-xs uppercase tracking-[0.18em] text-ice">{cat.name}</h3>
|
||||
<div className="lv-wall-viewport relative h-[300px] overflow-hidden">
|
||||
<ul className="lv-wall-track flex flex-col gap-1.5" style={{ ['--dur' as string]: `${SPEED_MS[i % SPEED_MS.length]}s` }}>
|
||||
{[...cat.modules, ...cat.modules].map((m, idx) => {
|
||||
const desc = cat.id === 'visuals' ? tc(`desc.${m.name}`) : undefined
|
||||
return (
|
||||
<li
|
||||
key={`${m.name}-${idx}`}
|
||||
title={desc}
|
||||
className="truncate rounded-md border border-white/5 bg-black/30 px-2.5 py-1.5 text-sm text-snow/90 hover:border-ice/40"
|
||||
>
|
||||
{m.name}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
52
frontend/src/features/landing/sections/ShowcaseTeaser.tsx
Normal file
52
frontend/src/features/landing/sections/ShowcaseTeaser.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { api } from '../../../shared/api/client'
|
||||
import '../styles/landing.css'
|
||||
|
||||
type ShowcaseItem = { id: string; name: string; author: string; downloads: number }
|
||||
|
||||
/** Placeholder cards so the section never collapses while loading or when the API is down. */
|
||||
const EXAMPLES: ShowcaseItem[] = [
|
||||
{ id: 'e1', name: 'Aura / Trails', author: 'example', downloads: 128 },
|
||||
{ id: 'e2', name: 'Clean PvP', author: 'example', downloads: 96 },
|
||||
{ id: 'e3', name: 'Stream Safe', author: 'example', downloads: 74 },
|
||||
]
|
||||
|
||||
export function ShowcaseTeaser() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['showcase', 'popular'],
|
||||
queryFn: () => api.request<ShowcaseItem[]>('/showcase?sort=popular'),
|
||||
})
|
||||
const real = !isError && Array.isArray(data) ? data.slice(0, 3) : []
|
||||
const cards = real.length >= 3 ? real : EXAMPLES
|
||||
const isExample = real.length < 3
|
||||
|
||||
return (
|
||||
<section aria-labelledby="showcase-title" className="grid gap-10 py-20 sm:py-28">
|
||||
<div className="flex min-w-0 flex-col items-start gap-4">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('showcase.eyebrow')}</span>
|
||||
<h2 id="showcase-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">{t('showcase.title')}</h2>
|
||||
<p className="max-w-[52ch] text-lg text-frost">{t('showcase.lead')}</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid min-w-0 gap-4 sm:grid-cols-3">
|
||||
{cards.map((c) => (
|
||||
<li key={c.id} className="lv-config-card flex flex-col gap-3 rounded-panel border border-white/10 bg-black/30 p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-display text-lg font-bold text-snow">{c.name}</span>
|
||||
{isExample ? <span className="lv-example-tag">{t('showcase.example')}</span> : null}
|
||||
</div>
|
||||
<span className="text-sm text-frost">{t('showcase.by', { author: c.author })}</span>
|
||||
<span className="font-code text-xs text-ice">{t('showcase.downloads', { count: c.downloads })}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link to="/showcase" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('showcase.cta')}
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
95
frontend/src/features/landing/sections/ThemeStrip.tsx
Normal file
95
frontend/src/features/landing/sections/ThemeStrip.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { motion } from 'motion/react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ButtonLink } from '../../../shared/ui/Button'
|
||||
import { themeToCssVars } from '../../clickgui/themeVars'
|
||||
import { PRESETS } from '../../themes/presets.generated'
|
||||
import { argbToCss, type ThemeColors } from '../../themes/types'
|
||||
import { useActiveTheme } from '../../themes/useActiveTheme'
|
||||
import { useInView } from '../effects/useInView'
|
||||
import '../styles/landing.css'
|
||||
|
||||
const PALETTE_KEYS: (keyof ThemeColors)[] = [
|
||||
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',
|
||||
]
|
||||
|
||||
/** Every built-in preset as a swatch; picking one re-themes the hero ClickGui and the page accent. */
|
||||
export function ThemeStrip() {
|
||||
const { t } = useTranslation('landing')
|
||||
const { t: tt } = useTranslation('themes')
|
||||
const { theme, setTheme } = useActiveTheme()
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const inView = useInView(listRef, { once: true, rootMargin: '-40px' })
|
||||
|
||||
return (
|
||||
<section aria-labelledby="themes-title" className="relative grid gap-10 py-20 sm:py-28 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)] lg:gap-14">
|
||||
<div className="flex min-w-0 flex-col items-start gap-5">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('themeStrip.eyebrow')}</span>
|
||||
<h2 id="themes-title" className="text-[2rem] leading-[1.1] sm:text-[2.6rem]">
|
||||
{t('themeStrip.title')}
|
||||
</h2>
|
||||
<p className="max-w-[40ch] text-lg text-frost">{t('themeStrip.lead')}</p>
|
||||
|
||||
<div className="lv-theme-now mt-2 w-full max-w-sm rounded-panel p-4" style={themeToCssVars(theme)}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-sm text-frost">{t('themeStrip.current')}</span>
|
||||
<span className="font-display text-lg font-bold" style={{ color: 'var(--gui-accent)' }}>
|
||||
{theme.name}
|
||||
</span>
|
||||
</div>
|
||||
<ul aria-label={t('themeStrip.palette')} className="mt-3 grid grid-cols-8 gap-1.5">
|
||||
{PALETTE_KEYS.map((k) => (
|
||||
<li
|
||||
key={k}
|
||||
title={tt(`colors.${k}`)}
|
||||
className="lv-chip aspect-square rounded-md"
|
||||
style={{ background: argbToCss(theme.theme[k]) }}
|
||||
>
|
||||
<span className="sr-only">{`${tt(`colors.${k}`)}: ${theme.theme[k]}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ButtonLink to="/themes" className="mt-2 px-5 py-3 text-base">
|
||||
{t('themeStrip.cta')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<ul ref={listRef} aria-label={t('themeStrip.swatches')} className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{PRESETS.map((preset, i) => {
|
||||
const vars = themeToCssVars(preset)
|
||||
const active = preset.id === theme.id
|
||||
return (
|
||||
<motion.li
|
||||
key={preset.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : undefined}
|
||||
transition={{ duration: 0.45, delay: (i % 6) * 0.05 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={tt('apply', { name: preset.name })}
|
||||
onClick={() => setTheme(preset.id, 'user')}
|
||||
className="lv-swatch group"
|
||||
data-active={active}
|
||||
style={vars}
|
||||
>
|
||||
<span className="lv-swatch-header" aria-hidden="true">
|
||||
<span className="lv-swatch-dot" />
|
||||
<span className="lv-swatch-bar" />
|
||||
</span>
|
||||
<span className="lv-swatch-body" aria-hidden="true">
|
||||
<span className="lv-swatch-card" />
|
||||
<span className="lv-swatch-card lv-swatch-card-on" />
|
||||
</span>
|
||||
<span className="lv-swatch-name">{preset.name}</span>
|
||||
</button>
|
||||
</motion.li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
211
frontend/src/features/landing/styles/landing.css
Normal file
211
frontend/src/features/landing/styles/landing.css
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/* Landing-only styling that is awkward as utility classes (layered gradients, glows, keyframes). */
|
||||
|
||||
.lv-bleed {
|
||||
width: 100vw;
|
||||
margin-left: calc(50% - 50vw);
|
||||
}
|
||||
|
||||
.lv-voxel {
|
||||
background: linear-gradient(180deg, #060818 0%, #120f2e 26%, #2b1a4a 46%, #6a2d5c 62%, #c25a55 76%, #f09a5c 88%, #f7b36a 100%);
|
||||
}
|
||||
|
||||
.lv-hero-shade {
|
||||
background:
|
||||
radial-gradient(80% 60% at 20% 45%, rgb(6 9 20 / 0.72), transparent 70%),
|
||||
linear-gradient(180deg, rgb(13 20 22 / 0.35) 0%, transparent 25%, transparent 70%, var(--color-abyss) 100%);
|
||||
}
|
||||
|
||||
.lv-hero-title {
|
||||
font-size: clamp(2.5rem, 5.4vw, 4.6rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
text-shadow: 0 4px 30px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.lv-hero-accent {
|
||||
display: inline-block;
|
||||
color: var(--color-ice);
|
||||
background: linear-gradient(100deg, #fff 0%, var(--color-ice) 45%, var(--color-ice) 70%, #fff 100%);
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 0 22px var(--page-accent-glow));
|
||||
animation: lv-sheen 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes lv-sheen {
|
||||
0%, 100% { background-position: 100% 0; }
|
||||
50% { background-position: 0 0; }
|
||||
}
|
||||
|
||||
.lv-cta-primary {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.4rem;
|
||||
border-radius: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--color-abyss);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--color-ice) 75%, #fff), var(--color-ice));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-ice) 60%, #fff) inset, 0 10px 40px -8px var(--page-accent-glow),
|
||||
0 0 60px -10px var(--page-accent-glow);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
.lv-cta-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 0 1px #fff inset, 0 16px 50px -8px var(--page-accent-glow), 0 0 80px -6px var(--page-accent-glow);
|
||||
}
|
||||
|
||||
.lv-live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ice);
|
||||
box-shadow: 0 0 0 0 var(--color-ice);
|
||||
animation: lv-pulse 2.2s ease-out infinite;
|
||||
}
|
||||
@keyframes lv-pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-ice) 70%, transparent); }
|
||||
100% { box-shadow: 0 0 0 9px transparent; }
|
||||
}
|
||||
|
||||
.lv-fact {
|
||||
background: linear-gradient(180deg, #fff, color-mix(in srgb, var(--color-ice) 70%, #fff));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.lv-theme-now {
|
||||
background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to));
|
||||
border: 1px solid color-mix(in srgb, var(--gui-accent) 35%, transparent);
|
||||
box-shadow: 0 0 40px -12px var(--gui-accent-glow);
|
||||
transition: box-shadow 400ms, border-color 400ms;
|
||||
}
|
||||
.lv-chip {
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background-image: none;
|
||||
transition: background-color 400ms;
|
||||
}
|
||||
|
||||
.lv-swatch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent);
|
||||
background: var(--gui-window-gradient);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0.9, 0.3, 1.2), box-shadow 300ms, border-color 300ms;
|
||||
}
|
||||
.lv-swatch:hover { transform: translateY(-3px); box-shadow: 0 12px 30px -10px var(--gui-accent-glow); }
|
||||
.lv-swatch[data-active="true"] {
|
||||
border-color: var(--gui-accent);
|
||||
box-shadow: 0 0 0 1px var(--gui-accent), 0 0 30px -4px var(--gui-accent-glow);
|
||||
}
|
||||
.lv-swatch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
background: var(--gui-header-gradient);
|
||||
}
|
||||
.lv-swatch-dot { width: 8px; height: 8px; border-radius: 2px; transform: rotate(45deg); background: var(--gui-accent); box-shadow: 0 0 8px var(--gui-accent); }
|
||||
.lv-swatch-bar { height: 4px; width: 38%; border-radius: 4px; background: color-mix(in srgb, var(--gui-text) 35%, transparent); }
|
||||
.lv-swatch-body { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; padding: 7px 8px 0; }
|
||||
.lv-swatch-card { height: 18px; border-radius: 4px; background: var(--gui-surface-hover); border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent); }
|
||||
.lv-swatch-card-on { background: var(--gui-card-gradient); border-color: color-mix(in srgb, var(--gui-accent) 50%, transparent); box-shadow: 0 0 10px -2px var(--gui-accent-glow); }
|
||||
.lv-swatch-name { padding: 7px 9px 8px; font-weight: 600; font-size: 0.9rem; color: var(--gui-text); }
|
||||
|
||||
/* HUD playground: a cropped voxel field with draggable, keyboard-movable widgets. */
|
||||
.lv-hud-field {
|
||||
height: clamp(300px, 46vw, 380px);
|
||||
background: linear-gradient(180deg, #0b0f22, #241a3d 55%, #3a2350);
|
||||
box-shadow: inset 0 0 60px -20px #000;
|
||||
}
|
||||
/* Same glass recipe as the ClickGui window (.lv-gui-window): themed gradient fill,
|
||||
accent border via the border-box trick, backdrop blur and an accent glow. */
|
||||
.lv-hud-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--gui-window-angle, 180deg),
|
||||
color-mix(in srgb, var(--gui-window-from, #12191b) 72%, transparent),
|
||||
color-mix(in srgb, var(--gui-window-to, #12191b) 72%, transparent)) padding-box,
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--gui-accent, #6cf) 55%, transparent),
|
||||
var(--gui-stroke-from, #2a3a3f) 35%, var(--gui-stroke-to, #2a3a3f) 70%,
|
||||
color-mix(in srgb, var(--gui-accent, #6cf) 30%, transparent)) border-box;
|
||||
box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 26px -8px var(--gui-accent-glow, transparent);
|
||||
backdrop-filter: blur(12px) saturate(1.25);
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
}
|
||||
.lv-hud-widget:active { cursor: grabbing; }
|
||||
.lv-hud-widget:focus-visible { outline: 2px solid var(--color-ice); outline-offset: 2px; }
|
||||
|
||||
/* Module wall: each column is a seamless vertical marquee (list is duplicated in the DOM). */
|
||||
.lv-wall-viewport { -webkit-mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); }
|
||||
.lv-wall-track { animation: lv-wall-scroll var(--dur, 40s) linear infinite; will-change: transform; }
|
||||
.lv-wall-col:hover .lv-wall-track { animation-play-state: paused; }
|
||||
@keyframes lv-wall-scroll {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-50%); }
|
||||
}
|
||||
|
||||
.lv-example-tag {
|
||||
font-family: var(--font-code, monospace);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-frost, #9fb0c0);
|
||||
border: 1px solid rgb(255 255 255 / 0.15);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.lv-config-card { transition: transform 220ms ease, border-color 220ms ease; }
|
||||
.lv-config-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--color-ice) 40%, transparent); }
|
||||
|
||||
.lv-download-band {
|
||||
background:
|
||||
radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--page-accent-glow, #6cf) 22%, transparent), transparent 60%),
|
||||
linear-gradient(180deg, #0e1430, #070a18);
|
||||
border: 1px solid color-mix(in srgb, var(--color-ice) 22%, transparent);
|
||||
}
|
||||
|
||||
.lv-faq-item > summary::-webkit-details-marker { display: none; }
|
||||
.lv-faq-marker { width: 10px; height: 10px; border-right: 2px solid var(--color-ice); border-bottom: 2px solid var(--color-ice); transform: rotate(45deg); transition: transform 200ms ease; }
|
||||
.lv-faq-item[open] .lv-faq-marker { transform: rotate(225deg); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lv-wall-track { animation: none; }
|
||||
}
|
||||
|
||||
/* Download page: numbered install steps and safe Markdown changelog styling. */
|
||||
.lv-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-ice) 18%, transparent);
|
||||
color: var(--color-ice, #6cf);
|
||||
font-family: var(--font-code, monospace);
|
||||
font-weight: 700;
|
||||
}
|
||||
.lv-markdown :where(h1, h2, h3) { font-weight: 700; color: var(--color-snow, #fff); margin: 0.6em 0 0.3em; }
|
||||
.lv-markdown :where(p, ul, ol) { margin: 0.4em 0; }
|
||||
.lv-markdown :where(ul, ol) { padding-left: 1.3em; }
|
||||
.lv-markdown :where(li) { margin: 0.15em 0; }
|
||||
.lv-markdown :where(code) { font-family: var(--font-code, monospace); background: rgb(255 255 255 / 0.08); padding: 0.1em 0.4em; border-radius: 4px; }
|
||||
.lv-markdown :where(a) { color: var(--color-ice, #6cf); text-decoration: underline; text-underline-offset: 3px; }
|
||||
41
frontend/src/features/landing/tests/landing.test.tsx
Normal file
41
frontend/src/features/landing/tests/landing.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { CommandHero } from '../CommandHero'
|
||||
import { Hero } from '../sections/Hero'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { SessionProvider } from '../../auth/session'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('reduced motion shows the full command at once', async () => {
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
const router = createMemoryRouter([{ path: '/', element: <CommandHero /> }])
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SessionProvider><RouterProvider router={router} /></SessionProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(await screen.findByText('%config load 7KQ3M9XA')).toBeInTheDocument()
|
||||
expect(screen.getByText('[LoVisual] Конфиг «pvp» загружен')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Пример: загрузка конфига по коду в чате игры')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('english locale renders the landing hero headline', async () => {
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
const i18n = initI18n('en')
|
||||
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent('you can see')
|
||||
})
|
||||
68
frontend/src/features/landing/tests/sections.test.tsx
Normal file
68
frontend/src/features/landing/tests/sections.test.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { mockFetch } from '../../../test/fetch'
|
||||
import { ParticleField } from '../effects/ParticleField'
|
||||
import { HudPlayground } from '../sections/HudPlayground'
|
||||
import { ModuleWall } from '../sections/ModuleWall'
|
||||
import { ShowcaseTeaser } from '../sections/ShowcaseTeaser'
|
||||
|
||||
const reduceMotion = () =>
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
|
||||
|
||||
function withQuery(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const router = createMemoryRouter([{ path: '/', element: ui }])
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
test('HudWidget moves by 8px with ArrowRight', () => {
|
||||
reduceMotion()
|
||||
withQuery(<HudPlayground />)
|
||||
const widget = screen.getByRole('button', { name: /FPS/ })
|
||||
const before = Number.parseInt(widget.style.left, 10)
|
||||
fireEvent.keyDown(widget, { key: 'ArrowRight' })
|
||||
expect(Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)).toBe(before + 8)
|
||||
})
|
||||
|
||||
test('HudWidget stays inside the playfield bounds', () => {
|
||||
reduceMotion()
|
||||
withQuery(<HudPlayground />)
|
||||
for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
|
||||
const left = Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)
|
||||
expect(left).toBeGreaterThanOrEqual(0)
|
||||
expect(left).toBeLessThanOrEqual(680)
|
||||
})
|
||||
|
||||
test('ModuleWall lists all four category headings', () => {
|
||||
reduceMotion()
|
||||
withQuery(<ModuleWall />)
|
||||
for (const name of ['Combat', 'Visuals', 'Player', 'Misc']) {
|
||||
expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
test('ShowcaseTeaser falls back to example cards on fetch error', async () => {
|
||||
reduceMotion()
|
||||
mockFetch({}) // no /showcase route -> 404 -> query error
|
||||
withQuery(<ShowcaseTeaser />)
|
||||
const tags = await screen.findAllByText('Пример')
|
||||
expect(tags).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('ParticleField schedules no animation frame under reduced motion', () => {
|
||||
const ctx = new Proxy({}, { get: () => () => ({ addColorStop() {} }), set: () => true })
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
reduceMotion()
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
render(<ParticleField color="rgb(10, 20, 30)" />)
|
||||
expect(raf).not.toHaveBeenCalled()
|
||||
;(HTMLCanvasElement.prototype.getContext as unknown as { mockRestore: () => void }).mockRestore()
|
||||
})
|
||||
70
frontend/src/features/themes/editor/ColorField.tsx
Normal file
70
frontend/src/features/themes/editor/ColorField.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useId, useState } from 'react'
|
||||
import { parseArgb } from '../types'
|
||||
|
||||
type Props = { label: string; value: string; onChange: (hex: string) => void }
|
||||
|
||||
const pad2 = (n: number) => n.toString(16).padStart(2, '0')
|
||||
const toRgb6 = (hex: string) => {
|
||||
const { r, g, b } = parseArgb(hex)
|
||||
return `#${pad2(r)}${pad2(g)}${pad2(b)}`.toUpperCase()
|
||||
}
|
||||
/** Rebuild `#AARRGGBB` from a `#RRGGBB` picker value, keeping the current alpha. */
|
||||
const withAlpha = (hex: string, rgb6: string) => `#${pad2(parseArgb(hex).a)}${rgb6.slice(1)}`.toUpperCase()
|
||||
/** Rebuild `#AARRGGBB` from an alpha (0–255), keeping the current rgb. */
|
||||
const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase()
|
||||
|
||||
/** One editable palette slot: native color picker + alpha slider + hex field. */
|
||||
export function ColorField({ label, value, onChange }: Props) {
|
||||
const id = useId()
|
||||
// Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes
|
||||
// for another reason (React's "adjust state during render" pattern).
|
||||
const [editing, setEditing] = useState<{ value: string; text: string }>({ value, text: value })
|
||||
const text = editing.value === value ? editing.text : value
|
||||
|
||||
const commitText = () => {
|
||||
const t = text.trim()
|
||||
if (/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) onChange(`#${t.replace(/^#/, '').toUpperCase()}`)
|
||||
else setEditing({ value, text: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<label htmlFor={id} className="lv-color-field flex items-center gap-2">
|
||||
<span
|
||||
className="lv-alpha-check size-8 shrink-0 rounded-md border border-white/15"
|
||||
style={{ backgroundImage: `linear-gradient(45deg, ${value}, ${value})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-xs text-frost">{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={label}
|
||||
value={toRgb6(value)}
|
||||
onChange={(e) => onChange(withAlpha(value, e.target.value))}
|
||||
className="size-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
aria-label={`${label} alpha`}
|
||||
value={parseArgb(value).a}
|
||||
onChange={(e) => onChange(withRgb(value, Number(e.target.value)))}
|
||||
className="lv-alpha-slider h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
value={text}
|
||||
onChange={(e) => setEditing({ value, text: e.target.value })}
|
||||
onBlur={commitText}
|
||||
onKeyDown={(e) => e.key === 'Enter' && commitText()}
|
||||
className="lv-hex-input w-[6.25rem] shrink-0 rounded-md border border-white/15 bg-black/40 px-2 py-1 font-code text-xs text-snow"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
28
frontend/src/features/themes/editor/EditorPreview.tsx
Normal file
28
frontend/src/features/themes/editor/EditorPreview.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useMemo } from 'react'
|
||||
import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
|
||||
import { themeToCssVars } from '../../clickgui/themeVars'
|
||||
import { VoxelScene } from '../../landing/effects/VoxelScene'
|
||||
import { parseArgb, type ThemeEntry } from '../types'
|
||||
|
||||
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */
|
||||
export function EditorPreview({ draft }: { draft: ThemeEntry }) {
|
||||
const vars = useMemo(() => themeToCssVars(draft), [draft])
|
||||
const { r, g, b } = parseArgb(draft.theme.accent)
|
||||
|
||||
return (
|
||||
<div className="lv-editor-preview relative min-h-[420px] overflow-hidden rounded-panel border border-white/10">
|
||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} className="absolute inset-0" />
|
||||
<div className="relative flex h-full flex-col items-center justify-center gap-6 p-6" style={vars}>
|
||||
<div className="flex w-full justify-center gap-4">
|
||||
<span className="lv-hud-tile" style={{ background: 'var(--gui-card-gradient)' }}>
|
||||
<b>FPS</b> 240
|
||||
</span>
|
||||
<span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}>
|
||||
<b>XYZ</b> 12 · 64 · -8
|
||||
</span>
|
||||
</div>
|
||||
<ClickGuiWindow theme={draft} demo={false} className="w-full max-w-[520px]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
frontend/src/features/themes/editor/GradientField.tsx
Normal file
47
frontend/src/features/themes/editor/GradientField.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { GradientSpec } from '../types'
|
||||
import { ColorField } from './ColorField'
|
||||
|
||||
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
|
||||
|
||||
/** A gradient slot: enable toggle, start/end colors and a 0–360° angle control. */
|
||||
export function GradientField({ title, value, onChange }: Props) {
|
||||
const { t } = useTranslation('themes')
|
||||
|
||||
return (
|
||||
<div className="lv-gradient-field flex flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-snow">{title}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-frost">
|
||||
{t('editor.enabled')}
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${title} · ${t('editor.enabled')}`}
|
||||
checked={value.enabled}
|
||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||
className="size-4 accent-[var(--color-ice)]"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<ColorField label={t('editor.start')} value={value.start} onChange={(start) => onChange({ start })} />
|
||||
{value.enabled && (
|
||||
<>
|
||||
<ColorField label={t('editor.end')} value={value.end} onChange={(end) => onChange({ end })} />
|
||||
<label className="flex items-center gap-2 text-xs text-frost">
|
||||
<span className="w-12 shrink-0">{t('editor.angle')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={359}
|
||||
value={Math.round(value.angle)}
|
||||
onChange={(e) => onChange({ angle: Number(e.target.value) })}
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right font-code text-snow">{Math.round(value.angle)}°</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
frontend/src/features/themes/editor/ThemeEditor.tsx
Normal file
155
frontend/src/features/themes/editor/ThemeEditor.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { PRESETS } from '../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
|
||||
import { EditorPreview } from './EditorPreview'
|
||||
import { ColorField } from './ColorField'
|
||||
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec'
|
||||
import { GradientField } from './GradientField'
|
||||
import { useThemeHistory } from './useThemeHistory'
|
||||
|
||||
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */
|
||||
const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
|
||||
|
||||
export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
||||
const { t } = useTranslation('themes')
|
||||
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const flash = useCallback((text: string) => {
|
||||
setNotice(text)
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setNotice(null), 2000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) redo()
|
||||
else undo()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [undo, redo])
|
||||
|
||||
const setColor = (slot: keyof ThemeColors, hex: string) => set({ ...draft, theme: { ...draft.theme, [slot]: hex } })
|
||||
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
|
||||
set({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } })
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard?.writeText(toProfileJson(draft))
|
||||
flash(t('editor.copied'))
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([toProfileJson(draft)], { type: 'application/json' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${draft.id}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const share = async () => {
|
||||
await navigator.clipboard?.writeText(`${location.origin}${location.pathname}#t=${toShareHash(draft)}`)
|
||||
flash(t('editor.shared'))
|
||||
}
|
||||
|
||||
const onFile = (file?: File) => {
|
||||
if (!file) return
|
||||
file.text().then((text) => {
|
||||
const result = fromProfileJson(text)
|
||||
if ('error' in result) flash(t('editor.importError'))
|
||||
else reset(result)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<EditorPreview draft={draft} />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
|
||||
{t('editor.undo')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={redo} disabled={!canRedo}>
|
||||
{t('editor.redo')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={() => reset(initial)}>
|
||||
{t('editor.reset')}
|
||||
</Button>
|
||||
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-frost">{t('editor.nameLabel')}</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => set({ ...draft, name: e.target.value, id: nameToId(e.target.value) })}
|
||||
className="rounded-md border border-white/15 bg-black/40 px-3 py-2 text-snow"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.presets')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => reset(p)}
|
||||
className={`lv-preset-chip ${p.id === draft.id ? 'is-active' : ''}`}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{COLOR_SLOTS.map((slot) => (
|
||||
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.gradients')}</h3>
|
||||
<div className="grid gap-3">
|
||||
{GRADIENT_SLOTS.map((g) => (
|
||||
<GradientField
|
||||
key={g.prefix}
|
||||
title={t(`editor.gradient.${g.prefix}`)}
|
||||
value={draft[g.key] as GradientSpec}
|
||||
onChange={(patch) => setGradient(g.key, patch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={copy}>{t('editor.copy')}</Button>
|
||||
<Button variant="quiet" onClick={download}>
|
||||
{t('editor.download')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={() => fileRef.current?.click()}>
|
||||
{t('editor.import')}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={share}>
|
||||
{t('editor.share')}
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept="application/json,.json" className="hidden" onChange={(e) => onFile(e.target.files?.[0])} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
frontend/src/features/themes/editor/codec.ts
Normal file
138
frontend/src/features/themes/editor/codec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { PRESETS } from '../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
|
||||
|
||||
// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as
|
||||
// `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`.
|
||||
// See mod ThemesProfileCodec.toProfileValues — keep these lists in lockstep.
|
||||
const COLOR_KEYS: (keyof ThemeColors)[] = [
|
||||
'windowBg',
|
||||
'windowHeader',
|
||||
'windowStroke',
|
||||
'surface',
|
||||
'surfaceHover',
|
||||
'cardEnabled',
|
||||
'cardDisabled',
|
||||
'textPrimary',
|
||||
'textMuted',
|
||||
'accent',
|
||||
'accentSoft',
|
||||
'strokeSoft',
|
||||
]
|
||||
|
||||
/** The 12 editable palette slots, in the mod's profile order. */
|
||||
export const COLOR_SLOTS = COLOR_KEYS
|
||||
|
||||
/** The 5 gradient slots: profile prefix + the matching `ThemeEntry` field. */
|
||||
export const GRADIENT_SLOTS = [
|
||||
{ prefix: 'window', key: 'windowGradient' },
|
||||
{ prefix: 'header', key: 'headerGradient' },
|
||||
{ prefix: 'surface', key: 'surfaceGradient' },
|
||||
{ prefix: 'card', key: 'cardGradient' },
|
||||
{ prefix: 'stroke', key: 'strokeGradient' },
|
||||
] as const satisfies { prefix: string; key: keyof ThemeEntry }[]
|
||||
|
||||
const GRADIENT_KEYS: [string, keyof ThemeEntry][] = [
|
||||
['window', 'windowGradient'],
|
||||
['header', 'headerGradient'],
|
||||
['surface', 'surfaceGradient'],
|
||||
['card', 'cardGradient'],
|
||||
['stroke', 'strokeGradient'],
|
||||
]
|
||||
|
||||
const CLASSIC = PRESETS[0]
|
||||
|
||||
type Profile = Record<string, string | boolean | number>
|
||||
|
||||
const upper = (hex: string) => hex.toUpperCase()
|
||||
|
||||
/** Pack an entry into the mod's flat profile object. */
|
||||
export function toProfile(entry: ThemeEntry): Profile {
|
||||
const out: Profile = { name: entry.name }
|
||||
for (const k of COLOR_KEYS) out[k] = upper(entry.theme[k])
|
||||
for (const [prefix, key] of GRADIENT_KEYS) {
|
||||
const g = entry[key] as GradientSpec
|
||||
out[`${prefix}GradientEnabled`] = g.enabled
|
||||
out[`${prefix}GradientStart`] = upper(g.start)
|
||||
out[`${prefix}GradientEnd`] = upper(g.end)
|
||||
out[`${prefix}GradientAngle`] = g.angle
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The mod's profile as an ordered JSON string (human-editable, keys match the mod exactly). */
|
||||
export function toProfileJson(entry: ThemeEntry): string {
|
||||
return JSON.stringify(toProfile(entry), null, 2)
|
||||
}
|
||||
|
||||
const str = (v: unknown, fallback: string) => (typeof v === 'string' && v ? upper(v) : fallback)
|
||||
const rawStr = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v : fallback)
|
||||
const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
|
||||
const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
|
||||
|
||||
function readGradient(map: Profile, prefix: string, fallback: GradientSpec): GradientSpec {
|
||||
return {
|
||||
enabled: bool(map[`${prefix}GradientEnabled`], fallback.enabled),
|
||||
start: str(map[`${prefix}GradientStart`], fallback.start),
|
||||
end: str(map[`${prefix}GradientEnd`], fallback.end),
|
||||
angle: num(map[`${prefix}GradientAngle`], fallback.angle),
|
||||
}
|
||||
}
|
||||
|
||||
/** Slug matching the mod's `uniqueCustomId` spirit: lowercase, non-alphanumeric → `_`. */
|
||||
const slug = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
|
||||
|
||||
function fromProfile(map: Profile): ThemeEntry {
|
||||
const theme = Object.fromEntries(
|
||||
COLOR_KEYS.map((k) => [k, str(map[k], CLASSIC.theme[k])]),
|
||||
) as unknown as ThemeColors
|
||||
const name = rawStr(map.name, 'Custom theme')
|
||||
return {
|
||||
id: slug(name),
|
||||
name,
|
||||
builtin: false,
|
||||
theme,
|
||||
windowGradient: readGradient(map, 'window', CLASSIC.windowGradient),
|
||||
headerGradient: readGradient(map, 'header', CLASSIC.headerGradient),
|
||||
surfaceGradient: readGradient(map, 'surface', CLASSIC.surfaceGradient),
|
||||
cardGradient: readGradient(map, 'card', CLASSIC.cardGradient),
|
||||
strokeGradient: readGradient(map, 'stroke', CLASSIC.strokeGradient),
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an entry from JSON. Tolerant: missing keys fall back to Classic; bad input → error. */
|
||||
export function fromProfileJson(json: string): ThemeEntry | { error: string } {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(json)
|
||||
} catch {
|
||||
return { error: 'invalidJson' }
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { error: 'invalidShape' }
|
||||
return fromProfile(parsed as Profile)
|
||||
}
|
||||
|
||||
// base64url without padding, so the share hash is URL-fragment safe.
|
||||
function b64url(text: string): string {
|
||||
const b64 = btoa(unescape(encodeURIComponent(text)))
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function unb64url(b64: string): string {
|
||||
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : ''
|
||||
const std = b64.replace(/-/g, '+').replace(/_/g, '/') + pad
|
||||
return decodeURIComponent(escape(atob(std)))
|
||||
}
|
||||
|
||||
/** Compact profile → URL-safe hash for the `#t=` share link. */
|
||||
export function toShareHash(entry: ThemeEntry): string {
|
||||
return b64url(JSON.stringify(toProfile(entry)))
|
||||
}
|
||||
|
||||
/** Decode a `#t=` hash back into an entry (or an error shape). */
|
||||
export function fromShareHash(hash: string): ThemeEntry | { error: string } {
|
||||
try {
|
||||
return fromProfileJson(unb64url(hash))
|
||||
} catch {
|
||||
return { error: 'invalidHash' }
|
||||
}
|
||||
}
|
||||
48
frontend/src/features/themes/editor/editor.css
Normal file
48
frontend/src/features/themes/editor/editor.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
|
||||
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
|
||||
|
||||
.lv-hud-tile {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--gui-stroke-soft, #6cf) 40%, transparent);
|
||||
color: var(--gui-text, #fff);
|
||||
font-family: var(--font-code, monospace);
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 6px 20px -12px #000;
|
||||
}
|
||||
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
|
||||
|
||||
.lv-preset-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgb(255 255 255 / 0.15);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: var(--color-frost, #9fb0c0);
|
||||
font-size: 0.8rem;
|
||||
transition: border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
.lv-preset-chip:hover { border-color: color-mix(in srgb, var(--color-ice, #6cf) 50%, transparent); color: var(--color-snow, #fff); }
|
||||
.lv-preset-chip.is-active { border-color: var(--color-ice, #6cf); color: var(--color-ice, #6cf); }
|
||||
|
||||
/* Checkerboard behind the swatch so partial alpha is visible. */
|
||||
.lv-alpha-check {
|
||||
background-color: #20242b;
|
||||
background-image:
|
||||
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%),
|
||||
linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%);
|
||||
background-size: 10px 10px;
|
||||
background-position: 0 0, 5px 5px;
|
||||
}
|
||||
|
||||
.lv-alpha-slider::-webkit-slider-thumb,
|
||||
.lv-alpha-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ice, #6cf);
|
||||
border: 2px solid #0b0f22;
|
||||
cursor: pointer;
|
||||
}
|
||||
85
frontend/src/features/themes/editor/tests/codec.test.ts
Normal file
85
frontend/src/features/themes/editor/tests/codec.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec'
|
||||
|
||||
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
|
||||
const PROFILE_KEYS = [
|
||||
'name',
|
||||
'windowBg',
|
||||
'windowHeader',
|
||||
'windowStroke',
|
||||
'surface',
|
||||
'surfaceHover',
|
||||
'cardEnabled',
|
||||
'cardDisabled',
|
||||
'textPrimary',
|
||||
'textMuted',
|
||||
'accent',
|
||||
'accentSoft',
|
||||
'strokeSoft',
|
||||
'windowGradientEnabled',
|
||||
'windowGradientStart',
|
||||
'windowGradientEnd',
|
||||
'windowGradientAngle',
|
||||
'headerGradientEnabled',
|
||||
'headerGradientStart',
|
||||
'headerGradientEnd',
|
||||
'headerGradientAngle',
|
||||
'surfaceGradientEnabled',
|
||||
'surfaceGradientStart',
|
||||
'surfaceGradientEnd',
|
||||
'surfaceGradientAngle',
|
||||
'cardGradientEnabled',
|
||||
'cardGradientStart',
|
||||
'cardGradientEnd',
|
||||
'cardGradientAngle',
|
||||
'strokeGradientEnabled',
|
||||
'strokeGradientStart',
|
||||
'strokeGradientEnd',
|
||||
'strokeGradientAngle',
|
||||
]
|
||||
|
||||
const editable = (e: (typeof PRESETS)[number]) => ({
|
||||
name: e.name,
|
||||
theme: e.theme,
|
||||
windowGradient: e.windowGradient,
|
||||
headerGradient: e.headerGradient,
|
||||
surfaceGradient: e.surfaceGradient,
|
||||
cardGradient: e.cardGradient,
|
||||
strokeGradient: e.strokeGradient,
|
||||
})
|
||||
|
||||
describe('theme profile codec', () => {
|
||||
test('JSON keys match the mod profile exactly', () => {
|
||||
expect(Object.keys(JSON.parse(toProfileJson(PRESETS[0])))).toEqual(PROFILE_KEYS)
|
||||
})
|
||||
|
||||
test('round-trips every preset through the profile JSON', () => {
|
||||
for (const preset of PRESETS) {
|
||||
const result = fromProfileJson(toProfileJson(preset))
|
||||
expect('error' in result).toBe(false)
|
||||
if (!('error' in result)) expect(editable(result)).toEqual(editable(preset))
|
||||
}
|
||||
})
|
||||
|
||||
test('share hash round-trips', () => {
|
||||
for (const preset of PRESETS) {
|
||||
const result = fromShareHash(toShareHash(preset))
|
||||
if ('error' in result) throw new Error('unexpected hash error')
|
||||
expect(editable(result)).toEqual(editable(preset))
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects malformed JSON and non-objects', () => {
|
||||
expect(fromProfileJson('{ nope')).toEqual({ error: 'invalidJson' })
|
||||
expect(fromProfileJson('[1,2,3]')).toEqual({ error: 'invalidShape' })
|
||||
expect(fromShareHash('%%%not-base64%%%')).toEqual({ error: 'invalidHash' })
|
||||
})
|
||||
|
||||
test('is tolerant: missing keys fall back to Classic', () => {
|
||||
const result = fromProfileJson(JSON.stringify({ name: 'Sparse' }))
|
||||
if ('error' in result) throw new Error('unexpected error')
|
||||
expect(result.name).toBe('Sparse')
|
||||
expect(result.theme).toEqual(PRESETS[0].theme)
|
||||
})
|
||||
})
|
||||
18
frontend/src/features/themes/editor/tests/editor.test.tsx
Normal file
18
frontend/src/features/themes/editor/tests/editor.test.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { ThemeEditor } from '../ThemeEditor'
|
||||
|
||||
test('editor shows the initial preset name and one toggle per gradient slot', () => {
|
||||
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||
const name = screen.getByDisplayValue('Classic')
|
||||
expect(name).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('checkbox', { name: /Градиент/ })).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('picking a preset chip loads it into the editor', () => {
|
||||
const next = PRESETS[1]
|
||||
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: next.name }))
|
||||
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
|
||||
})
|
||||
85
frontend/src/features/themes/editor/useThemeHistory.ts
Normal file
85
frontend/src/features/themes/editor/useThemeHistory.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { ThemeEntry } from '../types'
|
||||
|
||||
const LIMIT = 50
|
||||
/** Changes closer than this share one undo step, so a slider drag is a single revert. */
|
||||
const COALESCE_MS = 500
|
||||
|
||||
export type ThemeHistory = {
|
||||
draft: ThemeEntry
|
||||
/** Live edit; coalesced with recent edits of the same interaction. */
|
||||
set: (next: ThemeEntry) => void
|
||||
/** Replace the draft and clear history (preset pick, import, share-link load). */
|
||||
reset: (next: ThemeEntry) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
}
|
||||
|
||||
/** Undo/redo ring buffer (max 50 steps) for the theme editor draft. */
|
||||
export function useThemeHistory(initial: ThemeEntry): ThemeHistory {
|
||||
const [present, setPresent] = useState(initial)
|
||||
const presentRef = useRef(present)
|
||||
const past = useRef<ThemeEntry[]>([])
|
||||
const future = useRef<ThemeEntry[]>([])
|
||||
const lastEdit = useRef(0)
|
||||
const [flags, setFlags] = useState({ canUndo: false, canRedo: false })
|
||||
|
||||
const commit = useCallback((next: ThemeEntry) => {
|
||||
presentRef.current = next
|
||||
setPresent(next)
|
||||
setFlags({ canUndo: past.current.length > 0, canRedo: future.current.length > 0 })
|
||||
}, [])
|
||||
|
||||
const set = useCallback(
|
||||
(next: ThemeEntry) => {
|
||||
const now = Date.now()
|
||||
if (now - lastEdit.current > COALESCE_MS) {
|
||||
past.current = [...past.current, presentRef.current].slice(-LIMIT)
|
||||
future.current = []
|
||||
}
|
||||
lastEdit.current = now
|
||||
commit(next)
|
||||
},
|
||||
[commit],
|
||||
)
|
||||
|
||||
const reset = useCallback(
|
||||
(next: ThemeEntry) => {
|
||||
past.current = []
|
||||
future.current = []
|
||||
lastEdit.current = 0
|
||||
commit(next)
|
||||
},
|
||||
[commit],
|
||||
)
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (past.current.length === 0) return
|
||||
future.current = [presentRef.current, ...future.current].slice(0, LIMIT)
|
||||
lastEdit.current = 0
|
||||
commit(past.current.at(-1) as ThemeEntry)
|
||||
past.current = past.current.slice(0, -1)
|
||||
setFlags({ canUndo: past.current.length > 0, canRedo: true })
|
||||
}, [commit])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (future.current.length === 0) return
|
||||
past.current = [...past.current, presentRef.current].slice(-LIMIT)
|
||||
lastEdit.current = 0
|
||||
commit(future.current[0] as ThemeEntry)
|
||||
future.current = future.current.slice(1)
|
||||
setFlags({ canUndo: true, canRedo: future.current.length > 0 })
|
||||
}, [commit])
|
||||
|
||||
return {
|
||||
draft: present,
|
||||
set,
|
||||
reset,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: flags.canUndo,
|
||||
canRedo: flags.canRedo,
|
||||
}
|
||||
}
|
||||
49
frontend/src/features/themes/i18n/en.ts
Normal file
49
frontend/src/features/themes/i18n/en.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { Translation } from '../../../shared/i18n/common.ru'
|
||||
import type { themesRu } from './ru'
|
||||
|
||||
export const themesEn = {
|
||||
apply: 'Apply the {{name}} theme',
|
||||
colors: {
|
||||
windowBg: 'Window',
|
||||
windowHeader: 'Header',
|
||||
windowStroke: 'Stroke',
|
||||
surface: 'Surface',
|
||||
surfaceHover: 'Hover',
|
||||
cardEnabled: 'Card on',
|
||||
cardDisabled: 'Card off',
|
||||
textPrimary: 'Text',
|
||||
textMuted: 'Muted text',
|
||||
accent: 'Accent',
|
||||
accentSoft: 'Soft accent',
|
||||
strokeSoft: 'Soft stroke',
|
||||
},
|
||||
editor: {
|
||||
title: 'Theme editor',
|
||||
lead: 'Build your own theme and bring it into the mod as a single file.',
|
||||
nameLabel: 'Theme name',
|
||||
presets: 'Ready presets',
|
||||
colors: 'Colors',
|
||||
gradients: 'Gradients',
|
||||
enabled: 'Gradient',
|
||||
angle: 'Angle',
|
||||
start: 'Start',
|
||||
end: 'End',
|
||||
gradient: {
|
||||
window: 'Window',
|
||||
header: 'Header',
|
||||
surface: 'Surface',
|
||||
card: 'Card',
|
||||
stroke: 'Stroke',
|
||||
},
|
||||
copy: 'Copy for the mod',
|
||||
copied: 'Copied',
|
||||
download: 'Download .json',
|
||||
import: 'Import from file',
|
||||
share: 'Share link',
|
||||
shared: 'Link copied',
|
||||
reset: 'Reset',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
importError: 'Could not read the theme — the file is malformed.',
|
||||
},
|
||||
} satisfies Translation<typeof themesRu>
|
||||
46
frontend/src/features/themes/i18n/ru.ts
Normal file
46
frontend/src/features/themes/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export const themesRu = {
|
||||
apply: 'Применить тему «{{name}}»',
|
||||
colors: {
|
||||
windowBg: 'Фон окна',
|
||||
windowHeader: 'Шапка',
|
||||
windowStroke: 'Обводка',
|
||||
surface: 'Поверхность',
|
||||
surfaceHover: 'Наведение',
|
||||
cardEnabled: 'Карточка вкл.',
|
||||
cardDisabled: 'Карточка выкл.',
|
||||
textPrimary: 'Текст',
|
||||
textMuted: 'Приглушённый текст',
|
||||
accent: 'Акцент',
|
||||
accentSoft: 'Мягкий акцент',
|
||||
strokeSoft: 'Мягкая обводка',
|
||||
},
|
||||
editor: {
|
||||
title: 'Редактор тем',
|
||||
lead: 'Собери свою тему и перенеси её в мод одним файлом.',
|
||||
nameLabel: 'Название темы',
|
||||
presets: 'Готовые пресеты',
|
||||
colors: 'Цвета',
|
||||
gradients: 'Градиенты',
|
||||
enabled: 'Градиент',
|
||||
angle: 'Угол',
|
||||
start: 'Начало',
|
||||
end: 'Конец',
|
||||
gradient: {
|
||||
window: 'Окно',
|
||||
header: 'Шапка',
|
||||
surface: 'Поверхность',
|
||||
card: 'Карточка',
|
||||
stroke: 'Обводка',
|
||||
},
|
||||
copy: 'Скопировать для мода',
|
||||
copied: 'Скопировано',
|
||||
download: 'Скачать .json',
|
||||
import: 'Импорт из файла',
|
||||
share: 'Ссылка на тему',
|
||||
shared: 'Ссылка скопирована',
|
||||
reset: 'Сбросить',
|
||||
undo: 'Отменить',
|
||||
redo: 'Вернуть',
|
||||
importError: 'Не удалось прочитать тему — файл испорчен.',
|
||||
},
|
||||
} as const
|
||||
438
frontend/src/features/themes/presets.generated.ts
Normal file
438
frontend/src/features/themes/presets.generated.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
// generated — do not edit. Source: mod/.../features/theme/presets/ThemePresets{1,2,3}.java
|
||||
// Regenerate with `bun run gen:themes`.
|
||||
import type { ThemeEntry } from './types'
|
||||
|
||||
export const PRESETS: ThemeEntry[] = [
|
||||
{
|
||||
id: 'classic',
|
||||
name: 'Classic',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F012191B',
|
||||
windowHeader: '#F0142226',
|
||||
windowStroke: '#60203030',
|
||||
surface: '#16202523',
|
||||
surfaceHover: '#28353A3E',
|
||||
cardEnabled: '#5043A7C8',
|
||||
cardDisabled: '#00334046',
|
||||
textPrimary: '#FFFFFFFF',
|
||||
textMuted: '#88C6D2CD',
|
||||
accent: '#FF5CC8E7',
|
||||
accentSoft: '#805CC8E7',
|
||||
strokeSoft: '#60FFFFFF',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F012191B', end: '#F012191B', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#16202523', end: '#16202523', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#5043A7C8', end: '#5043A7C8', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60203030', end: '#60203030', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F0121314',
|
||||
windowHeader: '#F0000205',
|
||||
windowStroke: '#E1121314',
|
||||
surface: '#FF171819',
|
||||
surfaceHover: '#FF131315',
|
||||
cardEnabled: '#503F464D',
|
||||
cardDisabled: '#33191C20',
|
||||
textPrimary: '#FFF5F5FF',
|
||||
textMuted: '#88A5A5A5',
|
||||
accent: '#FF767C94',
|
||||
accentSoft: '#80767C94',
|
||||
strokeSoft: '#60373746',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F0121314', end: '#F0121314', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0141619', end: '#F0000205', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF171819', end: '#FF171819', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#503F464D', end: '#503F464D', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#E1121314', end: '#E1121314', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'amber',
|
||||
name: 'Amber',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F019120D',
|
||||
windowHeader: '#F0201711',
|
||||
windowStroke: '#60C3652C',
|
||||
surface: '#FF221711',
|
||||
surfaceHover: '#FF2B1E16',
|
||||
cardEnabled: '#50E08C3A',
|
||||
cardDisabled: '#40302620',
|
||||
textPrimary: '#FFFFF7EB',
|
||||
textMuted: '#88E3C9A7',
|
||||
accent: '#FFE69A43',
|
||||
accentSoft: '#80E69A43',
|
||||
strokeSoft: '#60F5D7B0',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F019120D', end: '#F019120D', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0322A24', end: '#F01E1610', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF221711', end: '#FF221711', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#50E08C3A', end: '#50E08C3A', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60C3652C', end: '#60C3652C', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'noir',
|
||||
name: 'Noir',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F00C0D10',
|
||||
windowHeader: '#F0121317',
|
||||
windowStroke: '#60393F4A',
|
||||
surface: '#FF14161B',
|
||||
surfaceHover: '#FF1C1F26',
|
||||
cardEnabled: '#5039424F',
|
||||
cardDisabled: '#40282D35',
|
||||
textPrimary: '#FFF4F4F4',
|
||||
textMuted: '#88C7CBD3',
|
||||
accent: '#FFE16B78',
|
||||
accentSoft: '#80E16B78',
|
||||
strokeSoft: '#605A6370',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F00C0D10', end: '#F00C0D10', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F025262A', end: '#F0111216', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF14161B', end: '#FF14161B', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#5039424F', end: '#5039424F', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60393F4A', end: '#60393F4A', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'purple',
|
||||
name: 'Purple',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F0120E1A',
|
||||
windowHeader: '#F0181323',
|
||||
windowStroke: '#60433A5A',
|
||||
surface: '#FF1C1627',
|
||||
surfaceHover: '#FF261D35',
|
||||
cardEnabled: '#505E4AA0',
|
||||
cardDisabled: '#40312644',
|
||||
textPrimary: '#FFF5F2FF',
|
||||
textMuted: '#88CFC6E6',
|
||||
accent: '#FF9B6BFF',
|
||||
accentSoft: '#809B6BFF',
|
||||
strokeSoft: '#60705A9C',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F0120E1A', end: '#F0120E1A', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F02A2635', end: '#F0171221', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF1C1627', end: '#FF1C1627', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#505E4AA0', end: '#505E4AA0', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60433A5A', end: '#60433A5A', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'blue',
|
||||
name: 'Blue',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F0080F1E',
|
||||
windowHeader: '#F00D1626',
|
||||
windowStroke: '#60405A82',
|
||||
surface: '#FF101B2B',
|
||||
surfaceHover: '#FF162338',
|
||||
cardEnabled: '#50406BB3',
|
||||
cardDisabled: '#4023324A',
|
||||
textPrimary: '#FFF0F5FF',
|
||||
textMuted: '#88B2C4E6',
|
||||
accent: '#FF2E64CC',
|
||||
accentSoft: '#803A6FD6',
|
||||
strokeSoft: '#60465F8E',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F0080F1E', end: '#F0080F1E', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0202937', end: '#F00C1524', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF101B2B', end: '#FF101B2B', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#50406BB3', end: '#50406BB3', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60405A82', end: '#60405A82', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
name: 'Azure',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#F00C1220',
|
||||
windowHeader: '#F0101A2A',
|
||||
windowStroke: '#60324B66',
|
||||
surface: '#FF121C2C',
|
||||
surfaceHover: '#FF18253A',
|
||||
cardEnabled: '#503F6BAA',
|
||||
cardDisabled: '#40283348',
|
||||
textPrimary: '#FFF2F7FF',
|
||||
textMuted: '#88BBD0EA',
|
||||
accent: '#FF5DA7FF',
|
||||
accentSoft: '#805DA7FF',
|
||||
strokeSoft: '#60507CA8',
|
||||
},
|
||||
windowGradient: { enabled: false, start: '#F00C1220', end: '#F00C1220', angle: 90 },
|
||||
headerGradient: { enabled: true, start: '#F0232C3B', end: '#F00F1827', angle: 90 },
|
||||
surfaceGradient: { enabled: false, start: '#FF121C2C', end: '#FF121C2C', angle: 90 },
|
||||
cardGradient: { enabled: false, start: '#503F6BAA', end: '#503F6BAA', angle: 90 },
|
||||
strokeGradient: { enabled: false, start: '#60324B66', end: '#60324B66', angle: 90 },
|
||||
},
|
||||
{
|
||||
id: 'nitro',
|
||||
name: 'Nitro',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF121624',
|
||||
windowHeader: '#FF181E33',
|
||||
windowStroke: '#60465580',
|
||||
surface: '#FF1A1F36',
|
||||
surfaceHover: '#FF232A45',
|
||||
cardEnabled: '#50A24CFF',
|
||||
cardDisabled: '#40303A52',
|
||||
textPrimary: '#FFF3F6FF',
|
||||
textMuted: '#88C7D3F3',
|
||||
accent: '#FF7A5CFF',
|
||||
accentSoft: '#807A5CFF',
|
||||
strokeSoft: '#60829AC9',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF36205F', end: '#FF0B7DFF', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF4B2FAF', end: '#FF24C1FF', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1A1F36', end: '#FF121728', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF5A37D5', end: '#FF2AD1FF', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF6C4BFF', end: '#FF4BD0FF', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
name: 'Sunset',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF1A1116',
|
||||
windowHeader: '#FF211319',
|
||||
windowStroke: '#605C2B2B',
|
||||
surface: '#FF24151C',
|
||||
surfaceHover: '#FF2E1A23',
|
||||
cardEnabled: '#50FF7A45',
|
||||
cardDisabled: '#40261A1A',
|
||||
textPrimary: '#FFFFF2E8',
|
||||
textMuted: '#88E3B9A3',
|
||||
accent: '#FFFF7A45',
|
||||
accentSoft: '#80FF7A45',
|
||||
strokeSoft: '#60F5C1A6',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2A1020', end: '#FFB2431F', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF3B1626', end: '#FFC25B2A', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF24151C', end: '#FF1E1116', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFFF8A4C', end: '#FFFF4B7A', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFFFB27A', end: '#FFFF6A3A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'aurora',
|
||||
name: 'Aurora',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF0D1A18',
|
||||
windowHeader: '#FF102320',
|
||||
windowStroke: '#602B5B57',
|
||||
surface: '#FF132623',
|
||||
surfaceHover: '#FF1A312D',
|
||||
cardEnabled: '#5034D399',
|
||||
cardDisabled: '#40304742',
|
||||
textPrimary: '#FFF1FFF9',
|
||||
textMuted: '#8897E3D0',
|
||||
accent: '#FF2DD9C3',
|
||||
accentSoft: '#802DD9C3',
|
||||
strokeSoft: '#6071B5A6',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF0B2A3C', end: '#FF2A8E6B', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF0E3B46', end: '#FF1FB88B', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF132623', end: '#FF10201D', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF2DD9C3', end: '#FF5A7CFF', angle: 135 },
|
||||
strokeGradient: { enabled: true, start: '#FF78FFE5', end: '#FF6FB1FF', angle: 120 },
|
||||
},
|
||||
{
|
||||
id: 'mint',
|
||||
name: 'Mint',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF0E1917',
|
||||
windowHeader: '#FF112320',
|
||||
windowStroke: '#60255B53',
|
||||
surface: '#FF132521',
|
||||
surfaceHover: '#FF1A2F2B',
|
||||
cardEnabled: '#5035CFA8',
|
||||
cardDisabled: '#40304742',
|
||||
textPrimary: '#FFF1FFF9',
|
||||
textMuted: '#8897E3D0',
|
||||
accent: '#FF43E0B2',
|
||||
accentSoft: '#8043E0B2',
|
||||
strokeSoft: '#6071B5A6',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF0B2A24', end: '#FF1B3E36', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF0F3A32', end: '#FF1A6E59', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF132521', end: '#FF0F1E1B', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF3DE3B4', end: '#FFB7F2D9', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF48E6B9', end: '#FF7EF0D6', angle: 120 },
|
||||
},
|
||||
{
|
||||
id: 'peach',
|
||||
name: 'Peach',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF1A1210',
|
||||
windowHeader: '#FF221510',
|
||||
windowStroke: '#605C3A2A',
|
||||
surface: '#FF241814',
|
||||
surfaceHover: '#FF2F211A',
|
||||
cardEnabled: '#50F2A269',
|
||||
cardDisabled: '#40382A22',
|
||||
textPrimary: '#FFFFF7F0',
|
||||
textMuted: '#88E3C9A7',
|
||||
accent: '#FFFFA46B',
|
||||
accentSoft: '#80FFB680',
|
||||
strokeSoft: '#60F5D7B0',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2A1512', end: '#FF4B241A', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF3B1B14', end: '#FF6A301F', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF241814', end: '#FF1C1310', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFFFB47A', end: '#FFFF7FA8', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFFFC28E', end: '#FFFF8A5A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'lilac',
|
||||
name: 'Lilac',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF14131E',
|
||||
windowHeader: '#FF1A1728',
|
||||
windowStroke: '#60433A5A',
|
||||
surface: '#FF1B172A',
|
||||
surfaceHover: '#FF241D36',
|
||||
cardEnabled: '#506C5CBE',
|
||||
cardDisabled: '#40312644',
|
||||
textPrimary: '#FFF5F2FF',
|
||||
textMuted: '#88CFC6E6',
|
||||
accent: '#FFB79BFF',
|
||||
accentSoft: '#80C8B0FF',
|
||||
strokeSoft: '#60705A9C',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF241A3B', end: '#FF1A3A55', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF2F214A', end: '#FF3D2F6A', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1B172A', end: '#FF15121F', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFB79BFF', end: '#FF7AD9FF', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFC5B0FF', end: '#FF8BB2FF', angle: 120 },
|
||||
},
|
||||
{
|
||||
id: 'sand',
|
||||
name: 'Sand',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF181510',
|
||||
windowHeader: '#FF201A13',
|
||||
windowStroke: '#6050432F',
|
||||
surface: '#FF221B14',
|
||||
surfaceHover: '#FF2C231B',
|
||||
cardEnabled: '#50E8C98C',
|
||||
cardDisabled: '#40322A21',
|
||||
textPrimary: '#FFFFF4E8',
|
||||
textMuted: '#88D8C4A2',
|
||||
accent: '#FFE8C98C',
|
||||
accentSoft: '#80F0D8A8',
|
||||
strokeSoft: '#607A6A54',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2A2016', end: '#FF3A2F22', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF32261B', end: '#FF4A3A2B', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF221B14', end: '#FF1A1510', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFF2D29B', end: '#FFE8B67A', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFF5E0B2', end: '#FFD1A36C', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'dusk',
|
||||
name: 'Dusk',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF141018',
|
||||
windowHeader: '#FF1B1422',
|
||||
windowStroke: '#60503A4A',
|
||||
surface: '#FF1D1626',
|
||||
surfaceHover: '#FF271B34',
|
||||
cardEnabled: '#50FF875A',
|
||||
cardDisabled: '#4030263B',
|
||||
textPrimary: '#FFF6F1FF',
|
||||
textMuted: '#88D6C9E6',
|
||||
accent: '#FFFF8A4C',
|
||||
accentSoft: '#80FF9B5A',
|
||||
strokeSoft: '#606B5A7A',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF2B1630', end: '#FF6B2A1F', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF3B1B3A', end: '#FF8A3B24', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1D1626', end: '#FF141018', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF7C4BFF', end: '#FFFF9B3D', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFA775FF', end: '#FFFF7A3A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'prism',
|
||||
name: 'Prism',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF0F1220',
|
||||
windowHeader: '#FF141A2B',
|
||||
windowStroke: '#60435173',
|
||||
surface: '#FF151C2E',
|
||||
surfaceHover: '#FF1B243A',
|
||||
cardEnabled: '#504B7CFF',
|
||||
cardDisabled: '#4031324A',
|
||||
textPrimary: '#FFF3F6FF',
|
||||
textMuted: '#88C2CDEB',
|
||||
accent: '#FF5AC8FA',
|
||||
accentSoft: '#8074B6FF',
|
||||
strokeSoft: '#606B7A9E',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF1A243A', end: '#FF2A1E4A', angle: 135 },
|
||||
headerGradient: { enabled: true, start: '#FF223050', end: '#FF352060', angle: 135 },
|
||||
surfaceGradient: { enabled: true, start: '#FF151C2E', end: '#FF101522', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF59C4FF', end: '#FFB35CFF', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF7AD4FF', end: '#FFE36BFF', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'cinder',
|
||||
name: 'Cinder',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF120E0F',
|
||||
windowHeader: '#FF181012',
|
||||
windowStroke: '#604A2F32',
|
||||
surface: '#FF1A1214',
|
||||
surfaceHover: '#FF23171A',
|
||||
cardEnabled: '#50D24A4A',
|
||||
cardDisabled: '#40281A1C',
|
||||
textPrimary: '#FFFFF3F3',
|
||||
textMuted: '#88E3B9B9',
|
||||
accent: '#FFDC4A4A',
|
||||
accentSoft: '#80E06A6A',
|
||||
strokeSoft: '#606B4E52',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF1A0C0C', end: '#FF2A1416', angle: 45 },
|
||||
headerGradient: { enabled: true, start: '#FF241012', end: '#FF3A191C', angle: 45 },
|
||||
surfaceGradient: { enabled: true, start: '#FF1A1214', end: '#FF120E0F', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FFDC4A4A', end: '#FF401010', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FFE26A6A', end: '#FF7A2A2A', angle: 45 },
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
name: 'Forest',
|
||||
builtin: true,
|
||||
theme: {
|
||||
windowBg: '#FF101611',
|
||||
windowHeader: '#FF141E15',
|
||||
windowStroke: '#6043573D',
|
||||
surface: '#FF161F18',
|
||||
surfaceHover: '#FF1D2B20',
|
||||
cardEnabled: '#5071C36C',
|
||||
cardDisabled: '#40263225',
|
||||
textPrimary: '#FFF3FFF1',
|
||||
textMuted: '#88C5E3C1',
|
||||
accent: '#FF7DD36B',
|
||||
accentSoft: '#807DD36B',
|
||||
strokeSoft: '#60607B58',
|
||||
},
|
||||
windowGradient: { enabled: true, start: '#FF1A241C', end: '#FF2B3A24', angle: 120 },
|
||||
headerGradient: { enabled: true, start: '#FF1F2B21', end: '#FF2F4B2E', angle: 120 },
|
||||
surfaceGradient: { enabled: true, start: '#FF161F18', end: '#FF101611', angle: 90 },
|
||||
cardGradient: { enabled: true, start: '#FF7DD36B', end: '#FFC6E886', angle: 45 },
|
||||
strokeGradient: { enabled: true, start: '#FF9BE07C', end: '#FF6BC26A', angle: 45 },
|
||||
},
|
||||
]
|
||||
54
frontend/src/features/themes/tests/activeTheme.test.tsx
Normal file
54
frontend/src/features/themes/tests/activeTheme.test.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { ActiveThemeProvider, useActiveTheme } from '../useActiveTheme'
|
||||
|
||||
function Probe() {
|
||||
const { theme, setTheme, userPicked } = useActiveTheme()
|
||||
return (
|
||||
<>
|
||||
<p data-testid="id">{theme.id}</p>
|
||||
<p data-testid="picked">{String(userPicked)}</p>
|
||||
<button onClick={() => setTheme('nitro', 'user')}>user</button>
|
||||
<button onClick={() => setTheme('sunset', 'demo')}>demo</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear()
|
||||
document.documentElement.removeAttribute('style')
|
||||
})
|
||||
|
||||
test('a user pick persists to lv_theme, re-accents the page and locks out demo switches', () => {
|
||||
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('classic')
|
||||
act(() => screen.getByText('demo').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('sunset')
|
||||
expect(window.localStorage.getItem('lv_theme')).toBeNull()
|
||||
act(() => screen.getByText('user').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
|
||||
expect(window.localStorage.getItem('lv_theme')).toBe('nitro')
|
||||
expect(document.documentElement.style.getPropertyValue('--color-ice')).toBe('rgb(122, 92, 255)')
|
||||
act(() => screen.getByText('demo').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
|
||||
})
|
||||
|
||||
test('restores the stored theme and survives blocked storage', () => {
|
||||
window.localStorage.setItem('lv_theme', 'mint')
|
||||
const { unmount } = render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('mint')
|
||||
expect(screen.getByTestId('picked')).toHaveTextContent('true')
|
||||
unmount()
|
||||
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
render(<ActiveThemeProvider><Probe /></ActiveThemeProvider>)
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('classic')
|
||||
act(() => screen.getByText('user').click())
|
||||
expect(screen.getByTestId('id')).toHaveTextContent('nitro')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
39
frontend/src/features/themes/tests/presets.test.ts
Normal file
39
frontend/src/features/themes/tests/presets.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../presets.generated'
|
||||
import { argbToCss, parseArgb } from '../types'
|
||||
|
||||
const javaSources = import.meta.glob<string>(
|
||||
'../../../../../mod/src/main/java/dev/loki/lovisual/features/theme/presets/ThemePresets*.java',
|
||||
{ query: '?raw', import: 'default', eager: true },
|
||||
)
|
||||
|
||||
test('generated presets include Classic with the mod accent', () => {
|
||||
const classic = PRESETS.find((p) => p.id === 'classic')
|
||||
expect(classic?.name).toBe('Classic')
|
||||
expect(classic?.theme.accent).toBe('#FF5CC8E7')
|
||||
expect(classic?.theme.windowBg).toBe('#F012191B')
|
||||
// 0x334046 in Java is a 6-digit literal: alpha 00.
|
||||
expect(classic?.theme.cardDisabled).toBe('#00334046')
|
||||
})
|
||||
|
||||
test('one preset per ThemeEntry constant in the mod sources', () => {
|
||||
const sources = Object.values(javaSources)
|
||||
expect(sources).toHaveLength(3)
|
||||
const count = sources.reduce((n, src) => n + (src.match(/static\s+final\s+ThemeEntry\s+\w+\s*=/g)?.length ?? 0), 0)
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(PRESETS).toHaveLength(count)
|
||||
expect(new Set(PRESETS.map((p) => p.id)).size).toBe(count)
|
||||
})
|
||||
|
||||
test('derived header gradient matches ThemesBlending.subtleHeaderGradient', () => {
|
||||
const classic = PRESETS.find((p) => p.id === 'classic')
|
||||
// 0xF0142226 lightened by 8% and darkened by 6%, as the mod computes it.
|
||||
expect(classic?.headerGradient).toEqual({ enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 })
|
||||
})
|
||||
|
||||
test('argbToCss converts packed ARGB', () => {
|
||||
expect(argbToCss('#F012191B')).toBe('rgba(18, 25, 27, 0.941)')
|
||||
expect(argbToCss('#FF5CC8E7')).toBe('rgba(92, 200, 231, 1)')
|
||||
expect(argbToCss('#00334046')).toBe('rgba(51, 64, 70, 0)')
|
||||
expect(parseArgb('#5CC8E7')).toEqual({ a: 255, r: 92, g: 200, b: 231 })
|
||||
})
|
||||
55
frontend/src/features/themes/types.ts
Normal file
55
frontend/src/features/themes/types.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/** The mod's 12 palette slots (`Themes.Theme` record), each as `#AARRGGBB`. */
|
||||
export type ThemeColors = {
|
||||
windowBg: string
|
||||
windowHeader: string
|
||||
windowStroke: string
|
||||
surface: string
|
||||
surfaceHover: string
|
||||
cardEnabled: string
|
||||
cardDisabled: string
|
||||
textPrimary: string
|
||||
textMuted: string
|
||||
accent: string
|
||||
accentSoft: string
|
||||
strokeSoft: string
|
||||
}
|
||||
|
||||
/** Mirrors `Themes.GradientSpec`: when disabled the slot renders flat with `start`. */
|
||||
export type GradientSpec = { enabled: boolean; start: string; end: string; angle: number }
|
||||
|
||||
/** Mirrors `Themes.ThemeEntry`. */
|
||||
export type ThemeEntry = {
|
||||
id: string
|
||||
name: string
|
||||
builtin: boolean
|
||||
theme: ThemeColors
|
||||
windowGradient: GradientSpec
|
||||
headerGradient: GradientSpec
|
||||
surfaceGradient: GradientSpec
|
||||
cardGradient: GradientSpec
|
||||
strokeGradient: GradientSpec
|
||||
}
|
||||
|
||||
type Rgba = { r: number; g: number; b: number; a: number }
|
||||
|
||||
/** Parses `#AARRGGBB` (the mod's packed ARGB) or `#RRGGBB` (opaque). */
|
||||
export function parseArgb(hex: string): Rgba {
|
||||
const clean = hex.replace(/^#/, '')
|
||||
const full = clean.length === 6 ? `FF${clean}` : clean.padStart(8, '0')
|
||||
const n = Number.parseInt(full, 16) >>> 0
|
||||
return { a: (n >>> 24) & 0xff, r: (n >>> 16) & 0xff, g: (n >>> 8) & 0xff, b: n & 0xff }
|
||||
}
|
||||
|
||||
const alpha = (a: number) => String(Number((a / 255).toFixed(3)))
|
||||
|
||||
/** `#F012191B` → `rgba(18, 25, 27, 0.941)`. */
|
||||
export function argbToCss(hex: string): string {
|
||||
const { r, g, b, a } = parseArgb(hex)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha(a)})`
|
||||
}
|
||||
|
||||
/** Same color with its alpha replaced (0–1) — for glows built from a palette slot. */
|
||||
export function withAlpha(hex: string, a: number): string {
|
||||
const { r, g, b } = parseArgb(hex)
|
||||
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`
|
||||
}
|
||||
72
frontend/src/features/themes/useActiveTheme.ts
Normal file
72
frontend/src/features/themes/useActiveTheme.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { createContext, createElement, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { PRESETS } from './presets.generated'
|
||||
import { parseArgb, type ThemeEntry, withAlpha } from './types'
|
||||
|
||||
const STORAGE_KEY = 'lv_theme'
|
||||
const FALLBACK = PRESETS[0]
|
||||
|
||||
type ActiveTheme = {
|
||||
theme: ThemeEntry
|
||||
/** True once the visitor picked a theme themselves — demos must stop switching it then. */
|
||||
userPicked: boolean
|
||||
/** `source: 'user'` persists the choice; `'demo'` is a transient showcase switch. */
|
||||
setTheme: (id: string, source?: 'user' | 'demo') => void
|
||||
}
|
||||
|
||||
const ActiveThemeContext = createContext<ActiveTheme | null>(null)
|
||||
|
||||
function readStored(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function store(id: string) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
// Storage can be blocked (private mode, quota); the theme still applies for this visit.
|
||||
}
|
||||
}
|
||||
|
||||
const findPreset = (id: string | null) => PRESETS.find((p) => p.id === id)
|
||||
|
||||
/** Opaque accent for page chrome (buttons, links, focus rings). */
|
||||
function pageAccent(entry: ThemeEntry): string {
|
||||
const { r, g, b } = parseArgb(entry.theme.accent)
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
export function ActiveThemeProvider({ children, applyToPage = true }: { children: ReactNode; applyToPage?: boolean }) {
|
||||
const [state, setState] = useState(() => {
|
||||
const stored = findPreset(readStored())
|
||||
return { theme: stored ?? FALLBACK, userPicked: Boolean(stored) }
|
||||
})
|
||||
|
||||
const setTheme = useCallback((id: string, source: 'user' | 'demo' = 'user') => {
|
||||
const next = findPreset(id)
|
||||
if (!next) return
|
||||
if (source === 'user') store(id)
|
||||
setState((prev) => {
|
||||
if (source === 'demo' && prev.userPicked) return prev
|
||||
return { theme: next, userPicked: prev.userPicked || source === 'user' }
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!applyToPage) return
|
||||
const root = document.documentElement.style
|
||||
root.setProperty('--color-ice', pageAccent(state.theme))
|
||||
root.setProperty('--page-accent-glow', withAlpha(state.theme.theme.accent, 0.35))
|
||||
}, [state.theme, applyToPage])
|
||||
|
||||
const value = useMemo(() => ({ ...state, setTheme }), [state, setTheme])
|
||||
return createElement(ActiveThemeContext.Provider, { value }, children)
|
||||
}
|
||||
|
||||
/** The active ClickGui theme. Outside a provider it falls back to Classic with a no-op setter. */
|
||||
export function useActiveTheme(): ActiveTheme {
|
||||
return useContext(ActiveThemeContext) ?? { theme: FALLBACK, userPicked: false, setTheme: () => {} }
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "@fontsource/comfortaa/400.css";
|
||||
@import "@fontsource/comfortaa/700.css";
|
||||
@import "@fontsource/iosevka/400.css";
|
||||
@import "@fontsource/iosevka/700.css";
|
||||
@import "@fontsource-variable/unbounded";
|
||||
@import "@fontsource-variable/onest";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
@theme {
|
||||
--color-abyss: #0d1416;
|
||||
|
|
@ -14,22 +12,38 @@
|
|||
--color-ice: #5cc8e7;
|
||||
--color-ember: #e8835a;
|
||||
|
||||
--font-display: "Comfortaa", ui-rounded, system-ui, sans-serif;
|
||||
--font-sans: "Inter Variable", system-ui, sans-serif;
|
||||
--font-code: "Iosevka", ui-monospace, monospace;
|
||||
--font-display: "Unbounded Variable", system-ui, sans-serif;
|
||||
--font-sans: "Onest Variable", system-ui, sans-serif;
|
||||
--font-code: "JetBrains Mono Variable", ui-monospace, monospace;
|
||||
|
||||
--text-hero: 3.8125rem;
|
||||
--text-hero--line-height: 1.05;
|
||||
--radius-panel: 10px;
|
||||
}
|
||||
|
||||
/* The page accent follows the active ClickGui theme (useActiveTheme sets it on :root);
|
||||
registering it as a color lets theme switches cross-fade instead of snapping. */
|
||||
@property --color-ice {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #5cc8e7;
|
||||
}
|
||||
@property --page-accent-glow {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: rgb(92 200 231 / 0.35);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
transition: --color-ice 400ms ease, --page-accent-glow 400ms ease;
|
||||
}
|
||||
body {
|
||||
@apply bg-abyss text-snow font-sans antialiased;
|
||||
min-height: 100dvh;
|
||||
/* Full-bleed sections span 100vw; never let that turn into a horizontal scrollbar. */
|
||||
overflow-x: clip;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { initI18n } from './app/i18n'
|
||||
import App from './app/App.tsx'
|
||||
|
||||
initI18n()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
76
frontend/src/pages/download/DownloadPage.tsx
Normal file
76
frontend/src/pages/download/DownloadPage.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DownloadButton } from '../../features/download/DownloadButton'
|
||||
import { ReleaseNotes } from '../../features/download/ReleaseNotes'
|
||||
import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
|
||||
|
||||
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
|
||||
const REQUIREMENTS = [
|
||||
{ key: 'reqMc', value: '26.2' },
|
||||
{ key: 'reqLoader', value: '0.19.4+' },
|
||||
{ key: 'reqApi', value: '0.152.2+26.2' },
|
||||
] as const
|
||||
|
||||
export default function DownloadPage() {
|
||||
const { t } = useTranslation('download')
|
||||
const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 })
|
||||
const latest = data?.[0]
|
||||
const steps = ['1', '2', '3'] as const
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-12 px-6 py-16">
|
||||
<header className="flex flex-col gap-4">
|
||||
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('page.title')}</h1>
|
||||
<p className="text-frost">{t('page.lead')}</p>
|
||||
<DownloadButton
|
||||
size="lg"
|
||||
className="mt-2"
|
||||
version={latest ? latest.tag_name.replace(/^v/, '') : undefined}
|
||||
bytes={latest ? releaseSize(latest) : undefined}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.reqTitle')}</h2>
|
||||
<ul className="grid gap-2 sm:grid-cols-3">
|
||||
{REQUIREMENTS.map((r) => (
|
||||
<li key={r.key} className="rounded-panel border border-white/10 bg-black/30 p-4">
|
||||
<span className="block text-xs text-frost">{t(`page.${r.key}`)}</span>
|
||||
<span className="font-code text-sm text-snow">{r.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-frost">
|
||||
<strong className="text-snow">{t('page.reqOptionalTitle')}</strong> {t('page.reqOptional')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('page.installTitle')}</h2>
|
||||
<ol className="flex flex-col gap-2">
|
||||
{steps.map((s) => (
|
||||
<li key={s} className="flex items-baseline gap-3">
|
||||
<span className="lv-step-num">{s}</span>
|
||||
<span className="text-snow/90">{t(`page.step${s}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="font-display text-xl font-bold uppercase text-snow">{t('notes.title')}</h2>
|
||||
<a
|
||||
href={`https://github.com/${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-ice underline-offset-4 hover:underline"
|
||||
>
|
||||
{t('notes.allReleases')}
|
||||
</a>
|
||||
</div>
|
||||
<ReleaseNotes />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/download/DownloadRoute.tsx
Normal file
12
frontend/src/pages/download/DownloadRoute.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
|
||||
// Loaded on demand so react-markdown stays out of the landing bundle.
|
||||
const DownloadPage = lazy(() => import('./DownloadPage'))
|
||||
|
||||
export default function DownloadRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
|
||||
<DownloadPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
23
frontend/src/pages/public/HomePage.tsx
Normal file
23
frontend/src/pages/public/HomePage.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { CommandHero } from '../../features/landing/CommandHero'
|
||||
import { HowItWorks } from '../../features/landing/HowItWorks'
|
||||
import { FaqDownload } from '../../features/landing/sections/FaqDownload'
|
||||
import { Hero } from '../../features/landing/sections/Hero'
|
||||
import { HudPlayground } from '../../features/landing/sections/HudPlayground'
|
||||
import { ModuleWall } from '../../features/landing/sections/ModuleWall'
|
||||
import { ShowcaseTeaser } from '../../features/landing/sections/ShowcaseTeaser'
|
||||
import { ThemeStrip } from '../../features/landing/sections/ThemeStrip'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<ThemeStrip />
|
||||
<HudPlayground />
|
||||
<CommandHero />
|
||||
<HowItWorks />
|
||||
<ModuleWall />
|
||||
<ShowcaseTeaser />
|
||||
<FaqDownload />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { LoginForm } from '../../features/auth/forms/LoginForm'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Вход</h1>
|
||||
<h1 className="text-3xl">{t('login.title')}</h1>
|
||||
<LoginForm />
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
17
frontend/src/pages/public/NotFoundPage.tsx
Normal file
17
frontend/src/pages/public/NotFoundPage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<section className="flex flex-col gap-4 py-16">
|
||||
<h1 className="text-[2.4375rem] leading-tight">{t('notFound.title')}</h1>
|
||||
<p className="text-frost">
|
||||
{t('notFound.body')}{' '}
|
||||
<Link to="/" className="text-ice underline-offset-4 hover:underline">
|
||||
{t('notFound.home')}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { t } = useTranslation('auth')
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Новый аккаунт</h1>
|
||||
<p className="max-w-sm text-frost">
|
||||
Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.
|
||||
</p>
|
||||
<h1 className="text-3xl">{t('register.title')}</h1>
|
||||
<p className="max-w-sm text-frost">{t('register.subtitle')}</p>
|
||||
<RegisterForm />
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
30
frontend/src/pages/themes/ThemesPage.tsx
Normal file
30
frontend/src/pages/themes/ThemesPage.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
|
||||
import { fromShareHash } from '../../features/themes/editor/codec'
|
||||
import { PRESETS } from '../../features/themes/presets.generated'
|
||||
import '../../features/themes/editor/editor.css'
|
||||
|
||||
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
|
||||
export default function ThemesPage() {
|
||||
const { t } = useTranslation('themes')
|
||||
|
||||
const initial = useMemo(() => {
|
||||
const shared = /#t=([^&]+)/.exec(window.location.hash)?.[1]
|
||||
if (shared) {
|
||||
const loaded = fromShareHash(shared)
|
||||
if (!('error' in loaded)) return loaded
|
||||
}
|
||||
return PRESETS[0]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-6 py-12">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="font-display text-4xl font-black uppercase text-snow">{t('editor.title')}</h1>
|
||||
<p className="text-frost">{t('editor.lead')}</p>
|
||||
</header>
|
||||
<ThemeEditor initial={initial} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
frontend/src/pages/themes/ThemesRoute.tsx
Normal file
12
frontend/src/pages/themes/ThemesRoute.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
|
||||
// Code-split so the editor and its CSS stay out of the landing bundle.
|
||||
const ThemesPage = lazy(() => import('./ThemesPage'))
|
||||
|
||||
export default function ThemesRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div className="px-6 py-16 text-frost">…</div>}>
|
||||
<ThemesPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export function createApiClient(
|
|||
headers.set('content-type', 'application/json')
|
||||
body = JSON.stringify(options.json)
|
||||
}
|
||||
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
if (accessToken && !path.startsWith('/auth/')) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
return fetchImpl(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import { ApiError } from './client'
|
||||
|
||||
const BY_STATUS: Record<number, string> = {
|
||||
400: 'Проверь введённые данные.',
|
||||
401: 'Нужно войти в аккаунт.',
|
||||
403: 'Недостаточно прав.',
|
||||
404: 'Не найдено.',
|
||||
409: 'Уже существует.',
|
||||
}
|
||||
const BY_STATUS = {
|
||||
400: 'errors.badRequest',
|
||||
401: 'errors.unauthorized',
|
||||
403: 'errors.forbidden',
|
||||
404: 'errors.notFound',
|
||||
409: 'errors.conflict',
|
||||
} as const
|
||||
|
||||
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
export function describeError(t: TFunction<'common'>, err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 429) {
|
||||
return err.retryAfter
|
||||
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
|
||||
: 'Слишком много попыток. Подожди немного.'
|
||||
? t('errors.rateLimited', { seconds: err.retryAfter })
|
||||
: t('errors.rateLimitedGeneric')
|
||||
}
|
||||
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
|
||||
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
|
||||
if (err.status >= 500) return t('errors.serverError')
|
||||
const key = BY_STATUS[err.status as keyof typeof BY_STATUS]
|
||||
return overrides[err.status] ?? (key ? t(key) : t('errors.badRequest'))
|
||||
}
|
||||
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
|
||||
return t('errors.network')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { ApiError, createApiClient } from '../client'
|
||||
import { describeError } from '../errors'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
const t = initI18n('ru').getFixedT('ru', 'common')
|
||||
|
||||
test('sends json with bearer token and cookies', async () => {
|
||||
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
|
||||
const api = createApiClient()
|
||||
|
|
@ -62,6 +65,15 @@ test('auth endpoints never trigger refresh', async () => {
|
|||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('never sends a stale bearer token on /auth/* requests', async () => {
|
||||
const fetch = mockFetch({ 'POST /auth/logout': () => new Response(null, { status: 204 }) })
|
||||
const api = createApiClient()
|
||||
api.setAccessToken('stale')
|
||||
await api.request('/auth/logout', { method: 'POST' })
|
||||
const [, init] = fetch.mock.calls[0]
|
||||
expect(new Headers(init?.headers).get('authorization')).toBeNull()
|
||||
})
|
||||
|
||||
test('429 carries retry-after; 204 resolves undefined', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
|
||||
|
|
@ -73,10 +85,10 @@ test('429 carries retry-after; 204 resolves undefined', async () => {
|
|||
})
|
||||
|
||||
test('describeError covers 429, network failure and status defaults', () => {
|
||||
expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
|
||||
expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
|
||||
expect(describeError(new TypeError('Failed to fetch'))).toBe(
|
||||
expect(describeError(t, new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.')
|
||||
expect(describeError(t, new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.')
|
||||
expect(describeError(t, new TypeError('Failed to fetch'))).toBe(
|
||||
'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
|
||||
)
|
||||
expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
expect(describeError(t, new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
})
|
||||
|
|
|
|||
24
frontend/src/shared/i18n/LanguageSwitch.tsx
Normal file
24
frontend/src/shared/i18n/LanguageSwitch.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LANGS = ['ru', 'en'] as const
|
||||
|
||||
export function LanguageSwitch() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const current = i18n.language
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm text-frost" role="group" aria-label="Language">
|
||||
{LANGS.map((lng) => (
|
||||
<button
|
||||
key={lng}
|
||||
type="button"
|
||||
aria-pressed={current === lng}
|
||||
onClick={() => i18n.changeLanguage(lng)}
|
||||
className={`rounded-md px-2 py-1 transition-colors ${current === lng ? 'text-snow' : 'hover:text-snow'}`}
|
||||
>
|
||||
{t(`languageSwitch.${lng}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
frontend/src/shared/i18n/common.en.ts
Normal file
52
frontend/src/shared/i18n/common.en.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { commonRu, Translation } from './common.ru'
|
||||
|
||||
export const commonEn = {
|
||||
skipToContent: 'Skip to content',
|
||||
nav: {
|
||||
mainNav: 'Main navigation',
|
||||
showcase: 'Showcase',
|
||||
myConfigs: 'My configs',
|
||||
linkGame: 'Link game',
|
||||
menu: 'Menu',
|
||||
login: 'Log in',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
body: 'This link may be outdated.',
|
||||
home: 'Go home',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
},
|
||||
languageSwitch: {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
footer: {
|
||||
about: 'A free visuals mod for Minecraft 26.2 on Fabric.',
|
||||
sectionProduct: 'Product',
|
||||
sectionProject: 'Project',
|
||||
github: 'GitHub',
|
||||
download: 'Download',
|
||||
legalTitle: 'Legal',
|
||||
disclaimer: 'Not affiliated with Mojang or Microsoft.',
|
||||
},
|
||||
time: {
|
||||
days_one: '{{count}} day',
|
||||
days_few: '{{count}} days',
|
||||
days_many: '{{count}} days',
|
||||
days_other: '{{count}} days',
|
||||
},
|
||||
errors: {
|
||||
badRequest: 'Check what you entered.',
|
||||
unauthorized: 'You need to log in.',
|
||||
forbidden: 'Not enough permissions.',
|
||||
notFound: 'Not found.',
|
||||
conflict: 'Already exists.',
|
||||
rateLimited: 'Too many attempts. Wait {{seconds}}s.',
|
||||
rateLimitedGeneric: 'Too many attempts. Wait a bit.',
|
||||
serverError: "The server didn't respond. Try again in a minute.",
|
||||
network: 'No connection to the server. Check your internet and try again.',
|
||||
},
|
||||
} satisfies Translation<typeof commonRu>
|
||||
54
frontend/src/shared/i18n/common.ru.ts
Normal file
54
frontend/src/shared/i18n/common.ru.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export const commonRu = {
|
||||
skipToContent: 'Перейти к содержимому',
|
||||
nav: {
|
||||
mainNav: 'Основная навигация',
|
||||
showcase: 'Витрина',
|
||||
myConfigs: 'Мои конфиги',
|
||||
linkGame: 'Привязать игру',
|
||||
menu: 'Меню',
|
||||
login: 'Войти',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Такой страницы нет',
|
||||
body: 'Возможно, ссылка устарела.',
|
||||
home: 'На главную',
|
||||
},
|
||||
shareCode: {
|
||||
copy: 'Скопировать',
|
||||
copied: 'Скопировано',
|
||||
},
|
||||
languageSwitch: {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
},
|
||||
footer: {
|
||||
about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.',
|
||||
sectionProduct: 'Продукт',
|
||||
sectionProject: 'Проект',
|
||||
github: 'GitHub',
|
||||
download: 'Скачать',
|
||||
legalTitle: 'Правовое',
|
||||
disclaimer: 'Не связано с Mojang или Microsoft.',
|
||||
},
|
||||
time: {
|
||||
days_one: '{{count}} день',
|
||||
days_few: '{{count}} дня',
|
||||
days_many: '{{count}} дней',
|
||||
days_other: '{{count}} дня',
|
||||
},
|
||||
errors: {
|
||||
badRequest: 'Проверь введённые данные.',
|
||||
unauthorized: 'Нужно войти в аккаунт.',
|
||||
forbidden: 'Недостаточно прав.',
|
||||
notFound: 'Не найдено.',
|
||||
conflict: 'Уже существует.',
|
||||
rateLimited: 'Слишком много попыток. Подожди {{seconds}} с.',
|
||||
rateLimitedGeneric: 'Слишком много попыток. Подожди немного.',
|
||||
serverError: 'Сервер не ответил. Попробуй через минуту.',
|
||||
network: 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Translation<T> = {
|
||||
[K in keyof T]: T[K] extends string ? string : Translation<T[K]>
|
||||
}
|
||||
33
frontend/src/shared/i18n/tests/i18n.test.tsx
Normal file
33
frontend/src/shared/i18n/tests/i18n.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next'
|
||||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '../../../app/i18n'
|
||||
import { LanguageSwitch } from '../LanguageSwitch'
|
||||
|
||||
function Probe() {
|
||||
const { t } = useTranslation('auth')
|
||||
return <p>{t('login.submit')}</p>
|
||||
}
|
||||
|
||||
test('switching language re-renders strings and sets <html lang>', async () => {
|
||||
const user = userEvent.setup()
|
||||
const i18n = initI18n('ru')
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Probe />
|
||||
<LanguageSwitch />
|
||||
</I18nextProvider>,
|
||||
)
|
||||
expect(screen.getByText('Войти')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'EN' }))
|
||||
expect(screen.getByText('Log in')).toBeInTheDocument()
|
||||
expect(document.documentElement.lang).toBe('en')
|
||||
})
|
||||
|
||||
test('russian plurals', () => {
|
||||
const i18n = initI18n('ru')
|
||||
expect(i18n.t('common:time.days', { count: 1 })).toBe('1 день')
|
||||
expect(i18n.t('common:time.days', { count: 3 })).toBe('3 дня')
|
||||
expect(i18n.t('common:time.days', { count: 5 })).toBe('5 дней')
|
||||
})
|
||||
23
frontend/src/shared/layout/AppShell.tsx
Normal file
23
frontend/src/shared/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Outlet } from 'react-router'
|
||||
import { Footer } from './Footer'
|
||||
import { TopBar } from './TopBar'
|
||||
|
||||
export function AppShell() {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-2 focus:z-10 focus:rounded-md focus:bg-slate focus:px-3 focus:py-2"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
<TopBar />
|
||||
<main id="main" className="mx-auto w-full max-w-[1120px] px-4 sm:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
49
frontend/src/shared/layout/Footer.tsx
Normal file
49
frontend/src/shared/layout/Footer.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
import { DOWNLOAD_URL, GITHUB_REPO } from '../../features/download/api'
|
||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||
|
||||
export function Footer() {
|
||||
const { t } = useTranslation('common')
|
||||
const product = [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/themes', label: 'Themes' },
|
||||
]
|
||||
const project = [
|
||||
{ href: `https://github.com/${GITHUB_REPO}`, label: t('footer.github') },
|
||||
{ href: DOWNLOAD_URL, label: t('footer.download') },
|
||||
]
|
||||
|
||||
return (
|
||||
<footer className="mt-8 border-t border-shoal bg-slate">
|
||||
<div className="mx-auto grid w-full max-w-[1120px] gap-10 px-4 py-12 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Link to="/" className="font-display text-xl font-bold text-snow">LoVisual</Link>
|
||||
<p className="max-w-[38ch] text-sm text-frost">{t('footer.about')}</p>
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
|
||||
<nav aria-label={t('footer.sectionProduct')} className="flex min-w-0 flex-col gap-2">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProduct')}</span>
|
||||
{product.map((i) => (
|
||||
<Link key={i.to} to={i.to} className="text-sm text-frost hover:text-snow">{i.label}</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<nav aria-label={t('footer.sectionProject')} className="flex min-w-0 flex-col gap-2">
|
||||
<span className="font-code text-xs uppercase tracking-[0.2em] text-ice">{t('footer.sectionProject')}</span>
|
||||
{project.map((i) => (
|
||||
<a key={i.label} href={i.href} target="_blank" rel="noreferrer" className="text-sm text-frost hover:text-snow">
|
||||
{i.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-shoal">
|
||||
<p className="mx-auto w-full max-w-[1120px] px-4 py-5 text-xs text-frost sm:px-6">{t('footer.disclaimer')}</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
94
frontend/src/shared/layout/TopBar.tsx
Normal file
94
frontend/src/shared/layout/TopBar.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, NavLink } from 'react-router'
|
||||
import { useSession } from '../../features/auth/session'
|
||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||
import type { Me } from '../types'
|
||||
import { ButtonLink } from '../ui/Button'
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation(['common', 'download'])
|
||||
const [open, setOpen] = useState(false)
|
||||
const nav = [
|
||||
{ to: '/showcase', label: t('nav.showcase') },
|
||||
{ to: '/configs', label: t('nav.myConfigs') },
|
||||
{ to: '/link', label: t('nav.linkGame') },
|
||||
]
|
||||
|
||||
return (
|
||||
<header className="border-b border-shoal bg-slate">
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-wrap items-center gap-x-6 px-4 sm:px-6">
|
||||
<Link to="/" className="py-4 font-display text-xl font-bold text-snow">
|
||||
LoVisual
|
||||
</Link>
|
||||
<nav
|
||||
id="site-nav"
|
||||
aria-label={t('nav.mainNav')}
|
||||
className={`${open ? 'flex' : 'hidden'} order-last w-full flex-col gap-1 pb-3 sm:order-none sm:flex sm:w-auto sm:flex-row sm:gap-6 sm:pb-0`}
|
||||
>
|
||||
{nav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`py-2 text-sm transition-colors ${isActive ? 'text-snow' : 'text-frost hover:text-snow'}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<ButtonLink to="/download" variant="primary">
|
||||
{t('download:nav')}
|
||||
</ButtonLink>
|
||||
<LanguageSwitch />
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="site-nav"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded-md px-2 py-2 text-sm text-frost hover:text-snow sm:hidden"
|
||||
>
|
||||
{t('nav.menu')}
|
||||
</button>
|
||||
<AccountButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountButton() {
|
||||
const { t } = useTranslation('common')
|
||||
const { status, me } = useSession()
|
||||
if (status === 'loading') return null
|
||||
if (!me) {
|
||||
return (
|
||||
<ButtonLink to="/login" variant="quiet">
|
||||
{t('nav.login')}
|
||||
</ButtonLink>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link to="/account" className="flex min-w-0 items-center gap-2 py-2 text-sm text-snow hover:text-ice">
|
||||
<Avatar me={me} />
|
||||
<span className="max-w-[8rem] truncate sm:max-w-[12rem]">{me.display_nick}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({ me }: { me: Me }) {
|
||||
if (me.avatar_url) {
|
||||
return <img src={me.avatar_url} alt="" className="size-7 shrink-0 rounded-full object-cover" />
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-shoal font-display text-xs font-bold text-snow"
|
||||
>
|
||||
{me.display_nick.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
47
frontend/src/shared/layout/tests/layout.test.tsx
Normal file
47
frontend/src/shared/layout/tests/layout.test.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { routes } from '../../../app/routes'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('anonymous visitor sees nav and a login link', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByRole('link', { name: 'Войти' })).toHaveAttribute('href', '/login')
|
||||
expect(screen.getAllByRole('link', { name: 'Витрина' }).length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, которые видно')
|
||||
})
|
||||
|
||||
test('signed-in visitor sees their nick instead of login', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/refresh': () => json({ access_token: 't' }),
|
||||
'GET /me': () => json({ id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '' }),
|
||||
})
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByRole('link', { name: /Rider/ })).toHaveAttribute('href', '/account')
|
||||
expect(screen.queryByRole('link', { name: 'Войти' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('unknown path shows not found', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/definitely-not-here')
|
||||
expect(await screen.findByRole('heading', { name: 'Такой страницы нет' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('menu disclosure toggles aria-expanded', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
const toggle = await screen.findByRole('button', { name: 'Меню' })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await userEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('footer carries the github link and the disclaimer', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({}, 401) })
|
||||
renderApp(routes, '/')
|
||||
expect(await screen.findByText('Не связано с Mojang или Microsoft.')).toBeInTheDocument()
|
||||
const gh = screen.getByRole('link', { name: 'GitHub' })
|
||||
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
|
||||
})
|
||||
7
frontend/src/shared/motion/reducedMotion.ts
Normal file
7
frontend/src/shared/motion/reducedMotion.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* True when the visitor asked for reduced motion. Unknown support (no matchMedia, e.g. jsdom)
|
||||
* counts as reduced: the safe default, and it keeps tests deterministic.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window.matchMedia !== 'function' || window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import { Link, type LinkProps } from 'react-router'
|
||||
|
||||
type Variant = 'primary' | 'quiet' | 'danger'
|
||||
|
||||
const BASE =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors'
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
primary: 'bg-ice text-abyss hover:bg-ice/90',
|
||||
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
|
||||
|
|
@ -21,8 +25,13 @@ export function Button({
|
|||
type={type}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
className={`${BASE} disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** A router link that looks like a button — for navigation CTAs. */
|
||||
export function ButtonLink({ variant = 'primary', className = '', ...rest }: LinkProps & { variant?: Variant }) {
|
||||
return <Link className={`${BASE} ${VARIANTS[variant]} ${className}`} {...rest} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from './Button'
|
||||
|
||||
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
||||
const { t } = useTranslation('common')
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
|
|
@ -19,10 +21,10 @@ export function ShareCode({ code, label }: { code: string; label?: string }) {
|
|||
{label ? <span className="text-sm text-frost">{label}</span> : null}
|
||||
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
|
||||
<Button variant="quiet" onClick={copy}>
|
||||
Скопировать
|
||||
{t('shareCode.copy')}
|
||||
</Button>
|
||||
<span aria-live="polite" className="text-sm text-frost">
|
||||
{copied ? 'Скопировано' : ''}
|
||||
{copied ? t('shareCode.copied') : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
import { initI18n } from '../app/i18n'
|
||||
|
||||
initI18n('ru')
|
||||
|
||||
afterEach(() => cleanup())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
// jsdom has no canvas backend; effects treat a null context as "nothing to draw".
|
||||
HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('router renders a route', () => {
|
||||
const router = createMemoryRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
render(<RouterProvider router={router} />)
|
||||
expect(screen.getByRole('heading', { name: 'LoVisual' })).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -19,5 +19,5 @@
|
|||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "scripts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { defineConfig } from 'vitest/config'
|
|||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
// Tests read the mod's theme presets (outside this package) to check the generated copy.
|
||||
fs: process.env.VITEST ? { allow: ['.', '../mod/src/main/java/dev/loki/lovisual/features/theme/presets'] } : undefined,
|
||||
// Same-origin in dev: the browser talks to /api, Vite forwards to the gateway.
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue