chore(history): squash 16 commit(s) from 2026-09-26
- chore(backend): sync Cargo.lock with gateway dev-dependency - feat(frontend): ClickGui replica matching the in-game GUI — category panels, Modules/Settings switch, theme cards - feat(frontend): floating glass top bar in the mod's UI language - fix(frontend): soft night backdrop for the ClickGui, editor that fits one screen - chore(frontend): replace em dashes in UI copy with lighter separators - refactor(frontend): split landing sections and theme editor into subfolders (4-files-per-folder rule) - feat(frontend): login and registration in the mod's glass panel style - feat(frontend): link the game with a device code - feat(frontend): account page with avatar upload and linked games - feat(frontend): my configs with share codes and publishing - feat(frontend): public showcase listing and detail pages - chore(frontend): drop em dashes from visible copy - feat(frontend): atmospheric backdrop, glass download block and ClickGui-style FAQ on the landing - feat(frontend): Konami code, DevTools console greeting and ASCII 404 block - feat: public player profiles at /u/:id and author link in the showcase - fix(frontend): stop the theme editor overflowing horizontally at 375px
This commit is contained in:
parent
7f4b532f99
commit
ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions
|
|
@ -4,24 +4,32 @@ import { themeToCssVars } from '../../clickgui/themeVars'
|
|||
import { VoxelScene } from '../../landing/effects/VoxelScene'
|
||||
import { parseArgb, type ThemeEntry } from '../types'
|
||||
|
||||
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */
|
||||
export function EditorPreview({ draft }: { draft: ThemeEntry }) {
|
||||
type Props = {
|
||||
draft: ThemeEntry
|
||||
/** A theme card clicked inside the preview's Settings screen: load it into the draft, not the site. */
|
||||
onPickPreset: (id: string) => void
|
||||
}
|
||||
|
||||
/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene, sized to fit its column. */
|
||||
export function EditorPreview({ draft, onPickPreset }: Props) {
|
||||
const vars = useMemo(() => themeToCssVars(draft), [draft])
|
||||
const { r, g, b } = parseArgb(draft.theme.accent)
|
||||
|
||||
return (
|
||||
<div className="lv-editor-preview relative min-h-[420px] overflow-hidden rounded-panel border border-white/10">
|
||||
<div className="lv-editor-preview relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-panel border border-white/10">
|
||||
<VoxelScene accent={`rgb(${r}, ${g}, ${b})`} className="absolute inset-0" />
|
||||
<div className="relative flex h-full flex-col items-center justify-center gap-6 p-6" style={vars}>
|
||||
<div className="flex w-full justify-center gap-4">
|
||||
<span className="lv-hud-tile" style={{ background: 'var(--gui-card-gradient)' }}>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4" style={vars}>
|
||||
<div className="flex shrink-0 justify-center gap-3">
|
||||
<span className="lv-hud-tile lv-hud-tile-card" style={{ background: 'var(--gui-card-gradient)' }}>
|
||||
<b>FPS</b> 240
|
||||
</span>
|
||||
<span className="lv-hud-tile" style={{ background: 'var(--gui-surface-gradient)' }}>
|
||||
<b>XYZ</b> 12 · 64 · -8
|
||||
</span>
|
||||
</div>
|
||||
<ClickGuiWindow theme={draft} demo={false} className="w-full max-w-[520px]" />
|
||||
<div className="lv-editor-stage">
|
||||
<ClickGuiWindow theme={draft} demo={false} onPickTheme={onPickPreset} className="lv-editor-gui" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { PRESETS } from '../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
|
||||
import { EditorPreview } from './EditorPreview'
|
||||
import { ColorField } from './ColorField'
|
||||
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec'
|
||||
import { GradientField } from './GradientField'
|
||||
import { useThemeHistory } from './useThemeHistory'
|
||||
import { ColorField } from './fields/ColorField'
|
||||
import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './model/codec'
|
||||
import { GradientField } from './fields/GradientField'
|
||||
import { useThemeHistory } from './model/useThemeHistory'
|
||||
|
||||
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */
|
||||
const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom'
|
||||
|
||||
export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
||||
/** `intro` (page title + lead) sits on top of the controls column so the whole editor fits one screen. */
|
||||
export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: ReactNode }) {
|
||||
const { t } = useTranslation('themes')
|
||||
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
|
@ -61,6 +62,11 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
flash(t('editor.shared'))
|
||||
}
|
||||
|
||||
const pickPreset = (id: string) => {
|
||||
const preset = PRESETS.find((p) => p.id === id)
|
||||
if (preset) reset(preset)
|
||||
}
|
||||
|
||||
const onFile = (file?: File) => {
|
||||
if (!file) return
|
||||
file.text().then((text) => {
|
||||
|
|
@ -71,10 +77,13 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<EditorPreview draft={draft} />
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 py-6 [grid-template-areas:'intro'_'preview'_'controls'] lg:h-[calc(100svh-var(--topbar-h))] lg:min-h-[560px] lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-x-8 lg:gap-y-4 lg:py-4 lg:[grid-template-areas:'preview_intro'_'preview_controls']">
|
||||
{intro && <div className="[grid-area:intro]">{intro}</div>}
|
||||
<div className="flex min-h-0 flex-col [grid-area:preview]">
|
||||
<EditorPreview draft={draft} onPickPreset={pickPreset} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-h-0 flex-col gap-4 [grid-area:controls]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
|
||||
{t('editor.undo')}
|
||||
|
|
@ -88,6 +97,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
{notice && <span className="ml-auto font-code text-xs text-ice">{notice}</span>}
|
||||
</div>
|
||||
|
||||
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-6 lg:-mr-3 lg:overflow-y-auto lg:pr-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-frost">{t('editor.nameLabel')}</span>
|
||||
<input
|
||||
|
|
@ -115,7 +125,7 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold uppercase text-frost">{t('editor.colors')}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
{COLOR_SLOTS.map((slot) => (
|
||||
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
|
||||
))}
|
||||
|
|
@ -136,7 +146,9 @@ export function ThemeEditor({ initial }: { initial: ThemeEntry }) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-white/10 pt-4 sm:grid-cols-4 lg:grid-cols-2">
|
||||
<Button onClick={copy}>{t('editor.copy')}</Button>
|
||||
<Button variant="quiet" onClick={download}>
|
||||
{t('editor.download')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
|
||||
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
|
||||
|
||||
/* On desktop the editor is one viewport tall: the stage is a size container and the ClickGui
|
||||
(narrow layout, 264:330) takes the largest width that still fits its height. */
|
||||
.lv-editor-gui { margin-inline: auto; }
|
||||
@media (min-width: 1024px) {
|
||||
.lv-editor-stage { flex: 1; min-height: 0; container-type: size; display: grid; place-items: center; }
|
||||
.lv-editor-gui { width: min(100cqw, 100cqh * 264 / 330); }
|
||||
}
|
||||
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: rgb(255 255 255 / 0.18) transparent; }
|
||||
|
||||
.lv-hud-tile {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
|
|
@ -14,6 +23,7 @@
|
|||
box-shadow: 0 6px 20px -12px #000;
|
||||
}
|
||||
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
|
||||
.lv-hud-tile-card b { color: var(--gui-text, #fff); }
|
||||
|
||||
.lv-preset-chip {
|
||||
padding: 4px 12px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useId, useState } from 'react'
|
||||
import { parseArgb } from '../types'
|
||||
import { argbToCss, parseArgb } from '../../types'
|
||||
|
||||
type Props = { label: string; value: string; onChange: (hex: string) => void }
|
||||
|
||||
|
|
@ -28,12 +28,10 @@ export function ColorField({ label, value, onChange }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<label htmlFor={id} className="lv-color-field flex items-center gap-2">
|
||||
<span
|
||||
className="lv-alpha-check size-8 shrink-0 rounded-md border border-white/15"
|
||||
style={{ backgroundImage: `linear-gradient(45deg, ${value}, ${value})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2">
|
||||
<span className="lv-alpha-check size-8 shrink-0 overflow-hidden rounded-md border border-white/15" aria-hidden="true">
|
||||
<span className="block size-full" style={{ background: argbToCss(value) }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-xs text-frost">{label}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { GradientSpec } from '../types'
|
||||
import type { GradientSpec } from '../../types'
|
||||
import { ColorField } from './ColorField'
|
||||
|
||||
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
|
||||
|
|
@ -9,7 +9,7 @@ export function GradientField({ title, value, onChange }: Props) {
|
|||
const { t } = useTranslation('themes')
|
||||
|
||||
return (
|
||||
<div className="lv-gradient-field flex flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
|
||||
<div className="lv-gradient-field flex min-w-0 flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-snow">{title}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-frost">
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { PRESETS } from '../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
|
||||
|
||||
// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as
|
||||
// `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { ThemeEntry } from '../types'
|
||||
import type { ThemeEntry } from '../../types'
|
||||
|
||||
const LIMIT = 50
|
||||
/** Changes closer than this share one undo step, so a slider drag is a single revert. */
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec'
|
||||
import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../model/codec'
|
||||
|
||||
// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order.
|
||||
const PROFILE_KEYS = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { PRESETS } from '../../presets.generated'
|
||||
import { ActiveThemeProvider, useActiveTheme } from '../../useActiveTheme'
|
||||
import { ThemeEditor } from '../ThemeEditor'
|
||||
|
||||
test('editor shows the initial preset name and one toggle per gradient slot', () => {
|
||||
|
|
@ -16,3 +18,24 @@ test('picking a preset chip loads it into the editor', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: next.name }))
|
||||
expect(screen.getByDisplayValue(next.name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
function SiteTheme() {
|
||||
return <output aria-label="site theme">{useActiveTheme().theme.id}</output>
|
||||
}
|
||||
|
||||
test('a theme card inside the preview loads that preset into the draft, not the site', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ActiveThemeProvider applyToPage={false}>
|
||||
<SiteTheme />
|
||||
<ThemeEditor initial={PRESETS[0]} />
|
||||
</ActiveThemeProvider>,
|
||||
)
|
||||
const gui = screen.getByTestId('clickgui-root')
|
||||
await user.click(within(gui).getByRole('tab', { name: 'Settings' }))
|
||||
await user.click(within(gui).getByRole('option', { name: 'Тема Nitro' }))
|
||||
expect(screen.getByDisplayValue('Nitro')).toBeInTheDocument()
|
||||
expect(within(gui).getByRole('option', { name: 'Тема Nitro' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('status', { name: 'site theme' })).toHaveTextContent(PRESETS[0].id)
|
||||
expect(window.localStorage.getItem('lv_theme')).toBeNull()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -44,6 +44,6 @@ export const themesEn = {
|
|||
reset: 'Reset',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
importError: 'Could not read the theme — the file is malformed.',
|
||||
importError: 'Could not read the theme: the file is malformed.',
|
||||
},
|
||||
} satisfies Translation<typeof themesRu>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@ export const themesRu = {
|
|||
reset: 'Сбросить',
|
||||
undo: 'Отменить',
|
||||
redo: 'Вернуть',
|
||||
importError: 'Не удалось прочитать тему — файл испорчен.',
|
||||
importError: 'Не удалось прочитать тему: файл испорчен.',
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue