LoVisual/frontend/src/features/themes/editor/ThemeEditor.tsx
loki5512344 ff2edc8eb6 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
2026-09-26 15:26:37 +02:00

167 lines
6.8 KiB
TypeScript

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 './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'
/** `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)
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 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) => {
const result = fromProfileJson(text)
if ('error' in result) flash(t('editor.importError'))
else reset(result)
})
}
return (
<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 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')}
</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>
<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
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 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)} />
))}
</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>
<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')}
</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>
)
}