chore(history): squash 16 commit(s) from 2026-09-26

- chore(backend): sync Cargo.lock with gateway dev-dependency
- feat(frontend): ClickGui replica matching the in-game GUI — category panels, Modules/Settings switch, theme cards
- feat(frontend): floating glass top bar in the mod's UI language
- fix(frontend): soft night backdrop for the ClickGui, editor that fits one screen
- chore(frontend): replace em dashes in UI copy with lighter separators
- refactor(frontend): split landing sections and theme editor into subfolders (4-files-per-folder rule)
- feat(frontend): login and registration in the mod's glass panel style
- feat(frontend): link the game with a device code
- feat(frontend): account page with avatar upload and linked games
- feat(frontend): my configs with share codes and publishing
- feat(frontend): public showcase listing and detail pages
- chore(frontend): drop em dashes from visible copy
- feat(frontend): atmospheric backdrop, glass download block and ClickGui-style FAQ on the landing
- feat(frontend): Konami code, DevTools console greeting and ASCII 404 block
- feat: public player profiles at /u/:id and author link in the showcase
- fix(frontend): stop the theme editor overflowing horizontally at 375px
This commit is contained in:
loki5512344 2026-09-26 15:26:37 +02:00
parent 7f4b532f99
commit ff2edc8eb6
116 changed files with 3534 additions and 1020 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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