feat(frontend): redesign the theme editor page
The editor's control panel is now a second ClickGui window skinned
live with the theme being edited, so every change shows in the tool
itself, not just the preview -- same "the section is a ClickGui
window" move the landing's ThemeStrip already uses, applied to a
working tool this time.
- Removed the three uppercase eyebrow-style section labels (the same
generic-tell pattern already fixed on the landing page); colour
slots are grouped into named fieldsets (Window/Modules/Text) instead
- Panel split into panel/{PanelHeader,PresetPicker,PaletteTabs,
ExportActions}.tsx to stay under the file-size limit
- Preset chips got a real swatch (accent dot over a mini card
gradient) -- previously same-hued presets like Classic/Blue/Azure
all read as identical blue dots
- Copy/share buttons confirm inline ("Скопировано"/"Ссылка
скопирована"); import/clipboard failures now surface a message
instead of failing silently
- Rewrote themes copy to match the landing's voice
Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
parent
fb853a26f4
commit
c2c7e8fa91
13 changed files with 793 additions and 246 deletions
|
|
@ -1,32 +1,27 @@
|
||||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
import { type ReactNode, useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@shared/ui/Button'
|
import { themeToCssVars } from '@features/clickgui/themeVars'
|
||||||
import { PRESETS } from '../presets.generated'
|
import { PRESETS } from '../presets.generated'
|
||||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
|
import type { ThemeEntry } from '../types'
|
||||||
import { EditorPreview } from './EditorPreview'
|
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'
|
import { useThemeHistory } from './model/useThemeHistory'
|
||||||
|
import { ExportActions } from './panel/ExportActions'
|
||||||
|
import { PaletteTabs } from './panel/PaletteTabs'
|
||||||
|
import { PanelHeader } from './panel/PanelHeader'
|
||||||
|
import { PresetPicker } from './panel/PresetPicker'
|
||||||
|
|
||||||
/** Pack the draft name into a slug id, mirroring the codec's `slug`. */
|
/** 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'
|
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. */
|
/**
|
||||||
|
* `intro` (page title + lead) sits on top of the controls column so the whole editor fits one screen.
|
||||||
|
* The controls column is itself a ClickGui window painted with the draft, so every edit shows up
|
||||||
|
* in the tool you are holding, not only in the preview.
|
||||||
|
*/
|
||||||
export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: ReactNode }) {
|
export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: ReactNode }) {
|
||||||
const { t } = useTranslation('themes')
|
const { t } = useTranslation('themes')
|
||||||
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
|
const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial)
|
||||||
const [notice, setNotice] = useState<string | null>(null)
|
const vars = useMemo(() => themeToCssVars(draft), [draft])
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -39,43 +34,11 @@ export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: R
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [undo, redo])
|
}, [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 pickPreset = (id: string) => {
|
||||||
const preset = PRESETS.find((p) => p.id === id)
|
const preset = PRESETS.find((p) => p.id === id)
|
||||||
if (preset) reset(preset)
|
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 (
|
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']">
|
<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>}
|
{intro && <div className="[grid-area:intro]">{intro}</div>}
|
||||||
|
|
@ -83,85 +46,18 @@ export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: R
|
||||||
<EditorPreview draft={draft} onPickPreset={pickPreset} />
|
<EditorPreview draft={draft} onPickPreset={pickPreset} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-col gap-4 [grid-area:controls]">
|
<section aria-label={t('editor.panel')} className="lv-editor-panel [grid-area:controls]" style={vars}>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<PanelHeader
|
||||||
<Button variant="quiet" onClick={undo} disabled={!canUndo}>
|
draft={draft}
|
||||||
{t('editor.undo')}
|
onRename={(name) => set({ ...draft, name, id: nameToId(name) })}
|
||||||
</Button>
|
history={{ undo, redo, canUndo, canRedo, reset: () => reset(initial), resetName: initial.name }}
|
||||||
<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-ctl border border-white/15 bg-black/40 px-3 py-2 text-snow"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-5 p-4 lg:overflow-y-auto">
|
||||||
|
<PresetPicker activeId={draft.id} onPick={pickPreset} />
|
||||||
<section className="flex flex-col gap-2">
|
<PaletteTabs draft={draft} onChange={set} />
|
||||||
<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>
|
</div>
|
||||||
|
<ExportActions draft={draft} onImport={reset} />
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */
|
/* Theme editor: preview pane, the draft-painted controls panel and its field affordances.
|
||||||
|
Everything inside .lv-editor-panel reads the draft's --gui-* vars (set on the panel), so the
|
||||||
|
panel is a second ClickGui window wearing the theme being built. */
|
||||||
.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); }
|
.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
|
/* On desktop the editor is one viewport tall: the stage is a size container and the ClickGui
|
||||||
|
|
@ -8,7 +10,11 @@
|
||||||
.lv-editor-stage { flex: 1; min-height: 0; container-type: size; display: grid; place-items: center; }
|
.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-gui { width: min(100cqw, 100cqh * 264 / 330); }
|
||||||
}
|
}
|
||||||
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: rgb(255 255 255 / 0.18) transparent; }
|
/* On desktop the controls scroll inside the panel; the bottom edge fades so it is clear there is more. */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.lv-editor-scroll { mask-image: linear-gradient(to bottom, #000 calc(100% - 2.5rem), transparent); padding-bottom: 2rem; }
|
||||||
|
}
|
||||||
|
.lv-editor-scroll { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--gui-text) 22%, transparent) transparent; }
|
||||||
|
|
||||||
.lv-hud-tile {
|
.lv-hud-tile {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -25,76 +31,161 @@
|
||||||
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
|
.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; }
|
||||||
.lv-hud-tile-card b { color: var(--gui-text, #fff); }
|
.lv-hud-tile-card b { color: var(--gui-text, #fff); }
|
||||||
|
|
||||||
|
/* The panel: window gradient, an accent glow in the corner, the theme's own outline. */
|
||||||
|
.lv-editor-panel {
|
||||||
|
--line: color-mix(in srgb, var(--gui-text) 14%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--gui-text);
|
||||||
|
background:
|
||||||
|
radial-gradient(90% 60% at 100% 0%, color-mix(in srgb, var(--gui-accent) 16%, transparent), transparent 70%),
|
||||||
|
linear-gradient(var(--gui-window-angle, 180deg), var(--gui-window-from), var(--gui-window-to)),
|
||||||
|
var(--color-abyss);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gui-stroke-soft) 35%, transparent), 0 30px 80px -36px var(--gui-accent-glow);
|
||||||
|
transition-property: --gui-window-from, --gui-window-to, --gui-header-from, --gui-header-to, --gui-text,
|
||||||
|
--gui-text-muted, --gui-accent, --gui-accent-soft, --gui-accent-glow, --gui-stroke-soft;
|
||||||
|
transition-duration: 400ms;
|
||||||
|
}
|
||||||
|
.lv-editor-panel :focus-visible { outline: 2px solid var(--gui-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.lv-editor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.55rem 0.6rem 0.55rem 1rem;
|
||||||
|
background: linear-gradient(var(--gui-header-angle, 90deg), var(--gui-header-from), var(--gui-header-to));
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 25%, transparent);
|
||||||
|
}
|
||||||
|
.lv-editor-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gui-accent);
|
||||||
|
box-shadow: 0 0 12px var(--gui-accent);
|
||||||
|
}
|
||||||
|
/* The theme name is the window title; a dotted underline says it can be typed into. */
|
||||||
|
.lv-editor-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gui-text);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
border-bottom: 1px dashed color-mix(in srgb, var(--gui-text) 28%, transparent);
|
||||||
|
}
|
||||||
|
.lv-editor-name:hover { border-bottom-color: color-mix(in srgb, var(--gui-text) 55%, transparent); }
|
||||||
|
.lv-editor-panel .lv-editor-name:focus-visible { outline: none; border-bottom: 1px solid var(--gui-accent); }
|
||||||
|
.lv-editor-tool {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--gui-text-muted);
|
||||||
|
transition: background-color 150ms, color 150ms;
|
||||||
|
}
|
||||||
|
.lv-editor-tool:hover:not(:disabled) { background: color-mix(in srgb, var(--gui-text) 10%, transparent); color: var(--gui-text); }
|
||||||
|
.lv-editor-tool:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.lv-editor-tool svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
|
||||||
|
/* Preset chips: a round swatch of the preset (its accent dot on its window) plus the name. */
|
||||||
.lv-preset-chip {
|
.lv-preset-chip {
|
||||||
padding: 4px 12px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 11px 3px 3px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgb(255 255 255 / 0.15);
|
border: 1px solid var(--line);
|
||||||
background: rgb(255 255 255 / 0.04);
|
color: var(--gui-text-muted);
|
||||||
color: var(--color-frost, #9fb0c0);
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
transition: border-color 160ms ease, color 160ms ease;
|
transition: border-color 150ms, color 150ms, background-color 150ms;
|
||||||
}
|
}
|
||||||
.lv-preset-chip:hover { border-color: color-mix(in srgb, var(--color-ice, #6cf) 50%, transparent); color: var(--color-snow, #fff); }
|
.lv-preset-chip:hover { color: var(--gui-text); border-color: color-mix(in srgb, var(--gui-text) 32%, transparent); }
|
||||||
.lv-preset-chip.is-active { border-color: var(--color-ice, #6cf); color: var(--color-ice, #6cf); }
|
.lv-preset-chip[aria-pressed="true"] {
|
||||||
|
color: var(--gui-text);
|
||||||
/* Checkerboard behind the swatch so partial alpha is visible. */
|
border-color: var(--gui-accent);
|
||||||
.lv-alpha-check {
|
background: color-mix(in srgb, var(--gui-accent) 16%, transparent);
|
||||||
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-preset-mini {
|
||||||
/* Alpha slider: `appearance: none` on the input alone leaves the UA thumb in Chrome,
|
width: 20px;
|
||||||
so reset it on the pseudo-elements and paint our own. */
|
height: 20px;
|
||||||
.lv-alpha-slider::-webkit-slider-thumb {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-ice, #6cf);
|
/* Accent dot on the preset's own enabled-card over its window, so neighbours with a similar
|
||||||
border: 2px solid #0b0f22;
|
accent (Classic, Blue, Azure) still read as different themes. */
|
||||||
cursor: pointer;
|
background: radial-gradient(circle, var(--chip-accent) 0 28%, transparent 33%), var(--chip-card), var(--chip-window);
|
||||||
}
|
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.18);
|
||||||
.lv-alpha-slider::-moz-range-thumb {
|
|
||||||
appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-ice, #6cf);
|
|
||||||
border: 2px solid #0b0f22;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native color input rendered as the checkerboard swatch itself: the real picker is
|
/* Colors / Gradients switch, shaped like the mod's Modules / Settings pill: a thumb slides under the active tab. */
|
||||||
transparent and fills the swatch, so a click opens it without showing UA chrome. */
|
.lv-editor-tabs {
|
||||||
.lv-color-swatch {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: inline-grid;
|
||||||
width: 100%;
|
grid-template-columns: 1fr 1fr;
|
||||||
height: 100%;
|
align-self: flex-start;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--gui-text) 7%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
}
|
}
|
||||||
.lv-color-swatch input[type='color'] {
|
.lv-editor-tabs::before {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.lv-color-swatch::after {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 3px auto 3px 3px;
|
||||||
border-radius: inherit;
|
width: calc(50% - 3px);
|
||||||
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-ice, #6cf) 70%, transparent);
|
border-radius: 999px;
|
||||||
opacity: 0;
|
background: color-mix(in srgb, var(--gui-accent) 30%, transparent);
|
||||||
transition: opacity 150ms ease;
|
box-shadow: inset 0 0 0 1px var(--gui-accent);
|
||||||
|
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
.lv-color-swatch:focus-within::after {
|
.lv-editor-tabs[data-tab="gradients"]::before { transform: translateX(100%); }
|
||||||
opacity: 1;
|
.lv-editor-tabs button {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.35rem 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gui-text-muted);
|
||||||
|
transition: color 200ms;
|
||||||
|
}
|
||||||
|
.lv-editor-tabs button[aria-selected="true"] { color: var(--gui-text); }
|
||||||
|
.lv-editor-group { padding: 0; margin-bottom: 0.6rem; font-size: 0.85rem; font-weight: 600; color: var(--gui-text); }
|
||||||
|
|
||||||
|
/* Footer: the ways out. Copy is filled with the theme's accent; a confirmed action flips its label. */
|
||||||
|
.lv-editor-foot {
|
||||||
|
padding: 0.75rem 1rem 0.85rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
.lv-editor-act {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: var(--radius-ctl);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gui-text) 20%, transparent);
|
||||||
|
color: var(--gui-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: border-color 150ms, background-color 150ms, box-shadow 200ms;
|
||||||
|
}
|
||||||
|
.lv-editor-act:hover { border-color: color-mix(in srgb, var(--gui-text) 45%, transparent); }
|
||||||
|
.lv-editor-act[data-primary="true"] { background: var(--gui-accent); border-color: var(--gui-accent); }
|
||||||
|
.lv-editor-act[data-primary="true"]:hover { background: color-mix(in srgb, var(--gui-accent) 88%, #fff); }
|
||||||
|
.lv-editor-act[data-done="true"] {
|
||||||
|
border-color: var(--gui-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--gui-accent) 30%, transparent);
|
||||||
|
animation: lv-act-done 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
@keyframes lv-act-done { 40% { transform: scale(0.96); } }
|
||||||
|
.lv-editor-status { font-size: 0.8rem; color: var(--gui-text-muted); }
|
||||||
|
.lv-editor-status:not(:empty) { margin-top: 0.6rem; }
|
||||||
|
.lv-editor-status[data-error="true"] { color: var(--color-ember); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.lv-editor-panel, .lv-editor-tabs::before, .lv-switch, .lv-switch::before { transition: none; }
|
||||||
|
.lv-editor-act[data-done="true"] { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useId, useState } from 'react'
|
import { type CSSProperties, useId, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { argbToCss, parseArgb } from '../../types'
|
import { argbToCss, parseArgb } from '../../types'
|
||||||
|
import './fields.css'
|
||||||
|
|
||||||
type Props = { label: string; value: string; onChange: (hex: string) => void }
|
type Props = { label: string; value: string; onChange: (hex: string) => void }
|
||||||
|
|
||||||
|
|
@ -15,6 +17,7 @@ const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`
|
||||||
|
|
||||||
/** One editable palette slot: native color picker + alpha slider + hex field. */
|
/** One editable palette slot: native color picker + alpha slider + hex field. */
|
||||||
export function ColorField({ label, value, onChange }: Props) {
|
export function ColorField({ label, value, onChange }: Props) {
|
||||||
|
const { t: tr } = useTranslation('themes')
|
||||||
const id = useId()
|
const id = useId()
|
||||||
// Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes
|
// 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).
|
// for another reason (React's "adjust state during render" pattern).
|
||||||
|
|
@ -29,7 +32,7 @@ export function ColorField({ label, value, onChange }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label htmlFor={id} className="lv-color-field flex w-full min-w-0 items-center gap-2">
|
<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">
|
<span className="lv-alpha-check lv-swatch-well size-9 shrink-0 overflow-hidden rounded-md">
|
||||||
<span className="lv-color-swatch" style={{ background: argbToCss(value) }}>
|
<span className="lv-color-swatch" style={{ background: argbToCss(value) }}>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
|
|
@ -41,16 +44,17 @@ export function ColorField({ label, value, onChange }: Props) {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<span className="truncate text-xs text-frost">{label}</span>
|
<span className="truncate text-xs text-[var(--gui-text-muted)]">{label}</span>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={255}
|
max={255}
|
||||||
aria-label={`${label} alpha`}
|
aria-label={tr('editor.alpha', { name: label })}
|
||||||
value={parseArgb(value).a}
|
value={parseArgb(value).a}
|
||||||
onChange={(e) => onChange(withRgb(value, Number(e.target.value)))}
|
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"
|
className="lv-alpha-slider lv-alpha-ramp h-2 min-w-0 flex-1 cursor-pointer appearance-none rounded-full"
|
||||||
|
style={{ '--ramp': toRgb6(value) } as CSSProperties}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
|
|
@ -60,7 +64,7 @@ export function ColorField({ label, value, onChange }: Props) {
|
||||||
onChange={(e) => setEditing({ value, text: e.target.value })}
|
onChange={(e) => setEditing({ value, text: e.target.value })}
|
||||||
onBlur={commitText}
|
onBlur={commitText}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && commitText()}
|
onKeyDown={(e) => e.key === 'Enter' && commitText()}
|
||||||
className="lv-hex-input w-[6.25rem] shrink-0 rounded-ctl border border-white/15 bg-black/40 px-2 py-1 font-code text-xs text-snow"
|
className="lv-hex-input lv-editor-input w-[6.25rem] shrink-0 px-2 py-1 font-code text-xs"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,41 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { GradientSpec } from '../../types'
|
import { cssAngle } from '@features/clickgui/themeVars'
|
||||||
|
import { argbToCss, type GradientSpec } from '../../types'
|
||||||
import { ColorField } from './ColorField'
|
import { ColorField } from './ColorField'
|
||||||
|
|
||||||
type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<GradientSpec>) => void }
|
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. */
|
/**
|
||||||
|
* A gradient slot. Its title bar is the gradient itself (at the mod's angle), so every tweak
|
||||||
|
* below is visible right where it is made; the switch turns the slot flat or back.
|
||||||
|
*/
|
||||||
export function GradientField({ title, value, onChange }: Props) {
|
export function GradientField({ title, value, onChange }: Props) {
|
||||||
const { t } = useTranslation('themes')
|
const { t } = useTranslation('themes')
|
||||||
|
const strip = value.enabled
|
||||||
|
? `linear-gradient(${cssAngle(value.angle)}deg, ${argbToCss(value.start)}, ${argbToCss(value.end)})`
|
||||||
|
: argbToCss(value.start)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lv-gradient-field flex min-w-0 flex-col gap-3 rounded-panel border border-white/10 bg-black/25 p-3">
|
<div className="lv-gradient-field" data-on={value.enabled}>
|
||||||
<label className="flex items-center justify-between gap-2">
|
<label className="lv-gradient-strip lv-alpha-check">
|
||||||
<span className="text-sm font-semibold text-snow">{title}</span>
|
<span className="lv-gradient-paint" style={{ background: strip }} aria-hidden="true" />
|
||||||
<span className="flex items-center gap-2 text-xs text-frost">
|
<span className="lv-gradient-title">{title}</span>
|
||||||
{t('editor.enabled')}
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${title} · ${t('editor.enabled')}`}
|
role="switch"
|
||||||
|
aria-label={t('editor.enabledFor', { name: title })}
|
||||||
checked={value.enabled}
|
checked={value.enabled}
|
||||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||||
className="size-4 accent-[var(--color-ice)]"
|
className="lv-switch"
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2.5 p-3">
|
||||||
<ColorField label={t('editor.start')} value={value.start} onChange={(start) => onChange({ start })} />
|
<ColorField label={t('editor.start')} value={value.start} onChange={(start) => onChange({ start })} />
|
||||||
{value.enabled && (
|
{value.enabled && (
|
||||||
<>
|
<>
|
||||||
<ColorField label={t('editor.end')} value={value.end} onChange={(end) => onChange({ end })} />
|
<ColorField label={t('editor.end')} value={value.end} onChange={(end) => onChange({ end })} />
|
||||||
<label className="flex items-center gap-2 text-xs text-frost">
|
<label className="flex items-center gap-2 text-xs text-[var(--gui-text-muted)]">
|
||||||
<span className="w-12 shrink-0">{t('editor.angle')}</span>
|
<span className="w-12 shrink-0">{t('editor.angle')}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|
@ -36,12 +43,13 @@ export function GradientField({ title, value, onChange }: Props) {
|
||||||
max={359}
|
max={359}
|
||||||
value={Math.round(value.angle)}
|
value={Math.round(value.angle)}
|
||||||
onChange={(e) => onChange({ angle: Number(e.target.value) })}
|
onChange={(e) => onChange({ angle: Number(e.target.value) })}
|
||||||
className="lv-alpha-slider h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/20"
|
className="lv-alpha-slider lv-track h-1.5 flex-1 cursor-pointer appearance-none rounded-full"
|
||||||
/>
|
/>
|
||||||
<span className="w-10 shrink-0 text-right font-code text-snow">{Math.round(value.angle)}°</span>
|
<span className="w-10 shrink-0 text-right font-code text-[var(--gui-text)]">{Math.round(value.angle)}°</span>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
120
frontend/src/features/themes/editor/fields/fields.css
Normal file
120
frontend/src/features/themes/editor/fields/fields.css
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/* Color and gradient fields. They sit inside .lv-editor-panel and read the draft's --gui-* vars. */
|
||||||
|
.lv-editor-input {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--gui-text) 6%, transparent);
|
||||||
|
color: var(--gui-text);
|
||||||
|
}
|
||||||
|
.lv-editor-panel .lv-editor-input:focus-visible { outline: none; border-color: var(--gui-accent); }
|
||||||
|
.lv-swatch-well { border: 1px solid color-mix(in srgb, var(--gui-text) 24%, transparent); }
|
||||||
|
|
||||||
|
/* Checkerboard behind swatches and the alpha ramp, 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;
|
||||||
|
}
|
||||||
|
/* The alpha slider's track is the color itself fading out, so the handle's position explains itself. */
|
||||||
|
.lv-alpha-ramp {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent, var(--ramp)),
|
||||||
|
linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 0 0 / 8px 8px,
|
||||||
|
linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 4px 4px / 8px 8px,
|
||||||
|
#2a2e35;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.lv-track { background: color-mix(in srgb, var(--gui-text) 18%, transparent); }
|
||||||
|
|
||||||
|
/* Slider thumbs: `appearance: none` on the input alone leaves the UA thumb in Chrome,
|
||||||
|
so reset it on the pseudo-elements and paint our own. */
|
||||||
|
.lv-alpha-slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gui-text, #fff);
|
||||||
|
border: 3px solid var(--gui-accent, var(--color-ice));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lv-alpha-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gui-text, #fff);
|
||||||
|
border: 3px solid var(--gui-accent, var(--color-ice));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Native color input rendered as the checkerboard swatch itself: the real picker is
|
||||||
|
transparent and fills the swatch, so a click opens it without showing UA chrome. */
|
||||||
|
.lv-color-swatch { position: relative; display: block; width: 100%; height: 100%; }
|
||||||
|
.lv-color-swatch input[type='color'] { position: absolute; inset: 0; opacity: 0; cursor: pointer; border: none; padding: 0; }
|
||||||
|
.lv-color-swatch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--gui-accent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
.lv-color-swatch:focus-within::after { opacity: 1; }
|
||||||
|
|
||||||
|
/* Gradient slot: the title bar is painted with the gradient itself; a switched-off slot fades back. */
|
||||||
|
.lv-gradient-field {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--gui-text) 3%, transparent);
|
||||||
|
}
|
||||||
|
.lv-gradient-strip {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lv-gradient-paint { position: absolute; inset: 0; transition: opacity 200ms; }
|
||||||
|
.lv-gradient-field[data-on="false"] .lv-gradient-paint { opacity: 0.3; }
|
||||||
|
.lv-gradient-title {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.lv-switch {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 180ms;
|
||||||
|
}
|
||||||
|
.lv-switch::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: calc(1.3rem - 6px);
|
||||||
|
height: calc(1.3rem - 6px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.lv-switch:checked { background: var(--gui-accent); }
|
||||||
|
.lv-switch:checked::before { transform: translateX(0.95rem); }
|
||||||
108
frontend/src/features/themes/editor/panel/ExportActions.tsx
Normal file
108
frontend/src/features/themes/editor/panel/ExportActions.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { parseArgb, type ThemeEntry } from '../../types'
|
||||||
|
import { fromProfileJson, toProfileJson, toShareHash } from '../model/codec'
|
||||||
|
|
||||||
|
type Props = { draft: ThemeEntry; onImport: (entry: ThemeEntry) => void }
|
||||||
|
/** Which button just worked (its label flips to the confirmation) or what to tell the player. */
|
||||||
|
type Status = { done?: 'copy' | 'share'; message?: string; error?: boolean }
|
||||||
|
|
||||||
|
/** Dark or light ink for text sitting on the theme's accent, whichever reads better. */
|
||||||
|
function inkOn(accent: string): string {
|
||||||
|
const { r, g, b } = parseArgb(accent)
|
||||||
|
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
||||||
|
return luma > 0.55 ? '#0d1416' : '#fff'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panel footer: get the theme out (clipboard, file, link) or bring one in from a file. */
|
||||||
|
export function ExportActions({ draft, onImport }: Props) {
|
||||||
|
const { t } = useTranslation('themes')
|
||||||
|
const [status, setStatus] = useState<Status>({})
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(timer.current), [])
|
||||||
|
|
||||||
|
const show = (next: Status, ms = 2000) => {
|
||||||
|
setStatus(next)
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => setStatus({}), ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toClipboard = async (text: string, done: 'copy' | 'share') => {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard) throw new Error('no clipboard')
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
show({ done, message: t(done === 'copy' ? 'editor.copied' : 'editor.shared') })
|
||||||
|
} catch {
|
||||||
|
show({ message: t('editor.clipboardError'), error: true }, 6000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 onFile = (file?: File) => {
|
||||||
|
if (!file) return
|
||||||
|
file.text().then((text) => {
|
||||||
|
const result = fromProfileJson(text)
|
||||||
|
if ('error' in result) {
|
||||||
|
show({ message: t('editor.importError'), error: true }, 6000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onImport(result)
|
||||||
|
show({ message: t('editor.imported', { name: result.name }) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lv-editor-foot">
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toClipboard(toProfileJson(draft), 'copy')}
|
||||||
|
className="lv-editor-act"
|
||||||
|
data-primary="true"
|
||||||
|
data-done={status.done === 'copy'}
|
||||||
|
style={{ color: inkOn(draft.theme.accent) }}
|
||||||
|
>
|
||||||
|
{status.done === 'copy' ? t('editor.copied') : t('editor.copy')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={download} className="lv-editor-act">
|
||||||
|
{t('editor.download')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => fileRef.current?.click()} className="lv-editor-act">
|
||||||
|
{t('editor.import')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toClipboard(`${location.origin}${location.pathname}#t=${toShareHash(draft)}`, 'share')}
|
||||||
|
className="lv-editor-act"
|
||||||
|
data-done={status.done === 'share'}
|
||||||
|
>
|
||||||
|
{status.done === 'share' ? t('editor.shared') : t('editor.share')}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
onFile(e.target.files?.[0])
|
||||||
|
e.target.value = '' // so picking the same file again still fires
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Copy and share confirm on the button itself; the line only speaks for them to screen readers. */}
|
||||||
|
<p role="status" className={`lv-editor-status ${status.done ? 'sr-only' : ''}`} data-error={status.error ?? false}>
|
||||||
|
{status.message ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
frontend/src/features/themes/editor/panel/PaletteTabs.tsx
Normal file
82
frontend/src/features/themes/editor/panel/PaletteTabs.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { type KeyboardEvent, useId, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
|
||||||
|
import { ColorField } from '../fields/ColorField'
|
||||||
|
import { GradientField } from '../fields/GradientField'
|
||||||
|
import { GRADIENT_SLOTS } from '../model/codec'
|
||||||
|
|
||||||
|
// The 12 slots grouped by the part of the menu they paint. Together they cover every
|
||||||
|
// COLOR_SLOTS entry once; the export order is still the codec's, this is display only.
|
||||||
|
const GROUPS: { key: 'window' | 'modules' | 'text'; slots: (keyof ThemeColors)[] }[] = [
|
||||||
|
{ key: 'window', slots: ['windowBg', 'windowHeader', 'windowStroke', 'strokeSoft'] },
|
||||||
|
{ key: 'modules', slots: ['surface', 'surfaceHover', 'cardEnabled', 'cardDisabled'] },
|
||||||
|
{ key: 'text', slots: ['textPrimary', 'textMuted', 'accent', 'accentSoft'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TABS = ['colors', 'gradients'] as const
|
||||||
|
type Tab = (typeof TABS)[number]
|
||||||
|
|
||||||
|
type Props = { draft: ThemeEntry; onChange: (next: ThemeEntry) => void }
|
||||||
|
|
||||||
|
/** Flat colors and gradients behind a two-way switch shaped like the mod's own tab pill. */
|
||||||
|
export function PaletteTabs({ draft, onChange }: Props) {
|
||||||
|
const { t } = useTranslation('themes')
|
||||||
|
const [tab, setTab] = useState<Tab>('colors')
|
||||||
|
const base = useId()
|
||||||
|
|
||||||
|
const setColor = (slot: keyof ThemeColors, hex: string) => onChange({ ...draft, theme: { ...draft.theme, [slot]: hex } })
|
||||||
|
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
|
||||||
|
onChange({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } })
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||||
|
const next = tab === 'colors' ? 'gradients' : 'colors'
|
||||||
|
setTab(next)
|
||||||
|
document.getElementById(`${base}-${next}-tab`)?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div role="tablist" aria-label={t('editor.palette')} className="lv-editor-tabs" data-tab={tab} onKeyDown={onKey}>
|
||||||
|
{TABS.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
id={`${base}-${key}-tab`}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === key}
|
||||||
|
aria-controls={`${base}-${key}`}
|
||||||
|
tabIndex={tab === key ? 0 : -1}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
>
|
||||||
|
{t(`editor.${key}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id={`${base}-colors`} role="tabpanel" aria-labelledby={`${base}-colors-tab`} hidden={tab !== 'colors'} className="flex flex-col gap-5">
|
||||||
|
{GROUPS.map((group) => (
|
||||||
|
<fieldset key={group.key} className="flex min-w-0 flex-col gap-2.5">
|
||||||
|
<legend className="lv-editor-group">{t(`editor.group.${group.key}`)}</legend>
|
||||||
|
<div className="grid gap-x-4 gap-y-2.5 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||||
|
{group.slots.map((slot) => (
|
||||||
|
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id={`${base}-gradients`} role="tabpanel" aria-labelledby={`${base}-gradients-tab`} hidden={tab !== 'gradients'} className="flex flex-col 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
frontend/src/features/themes/editor/panel/PanelHeader.tsx
Normal file
56
frontend/src/features/themes/editor/panel/PanelHeader.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { ThemeEntry } from '../../types'
|
||||||
|
|
||||||
|
type History = { undo: () => void; redo: () => void; reset: () => void; resetName: string; canUndo: boolean; canRedo: boolean }
|
||||||
|
type Props = { draft: ThemeEntry; onRename: (name: string) => void; history: History }
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
undo: 'M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 0 11H11',
|
||||||
|
redo: 'm15 14 5-5-5-5M20 9H9.5a5.5 5.5 0 0 0 0 11H13',
|
||||||
|
reset: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel's title bar, painted with the draft's header gradient like the mod's window header.
|
||||||
|
* The theme name is the title itself and is edited in place; history controls sit on the right.
|
||||||
|
*/
|
||||||
|
export function PanelHeader({ draft, onRename, history }: Props) {
|
||||||
|
const { t } = useTranslation('themes')
|
||||||
|
const tools = [
|
||||||
|
{ key: 'undo', label: t('editor.undo'), keys: 'Control+Z', run: history.undo, enabled: history.canUndo },
|
||||||
|
{ key: 'redo', label: t('editor.redo'), keys: 'Control+Shift+Z', run: history.redo, enabled: history.canRedo },
|
||||||
|
{ key: 'reset', label: t('editor.reset', { name: history.resetName }), keys: undefined, run: history.reset, enabled: true },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lv-editor-head">
|
||||||
|
<span className="lv-editor-dot" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => onRename(e.target.value)}
|
||||||
|
aria-label={t('editor.nameLabel')}
|
||||||
|
title={t('editor.nameLabel')}
|
||||||
|
spellCheck={false}
|
||||||
|
className="lv-editor-name"
|
||||||
|
/>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<button
|
||||||
|
key={tool.key}
|
||||||
|
type="button"
|
||||||
|
onClick={tool.run}
|
||||||
|
disabled={!tool.enabled}
|
||||||
|
aria-label={tool.label}
|
||||||
|
aria-keyshortcuts={tool.keys}
|
||||||
|
title={tool.keys ? `${tool.label} (${tool.keys.replace('Control', 'Ctrl')})` : tool.label}
|
||||||
|
className="lv-editor-tool"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d={ICONS[tool.key]} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
frontend/src/features/themes/editor/panel/PresetPicker.tsx
Normal file
45
frontend/src/features/themes/editor/panel/PresetPicker.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { themeToCssVars } from '@features/clickgui/themeVars'
|
||||||
|
import { PRESETS } from '../../presets.generated'
|
||||||
|
|
||||||
|
// Each chip's swatch is painted with its own preset, so the list reads as a palette before anything is
|
||||||
|
// clicked. The chip's text and outline still follow the draft, like the rest of the panel.
|
||||||
|
// A flat color can only be the last background layer, so the card is always passed as a gradient.
|
||||||
|
const asLayer = (paint: string) => (paint.includes('gradient(') ? paint : `linear-gradient(${paint}, ${paint})`)
|
||||||
|
|
||||||
|
const CHIPS = PRESETS.map((p) => {
|
||||||
|
const v = themeToCssVars(p)
|
||||||
|
const vars = {
|
||||||
|
'--chip-window': v['--gui-window-gradient'],
|
||||||
|
'--chip-card': asLayer(v['--gui-card-gradient']),
|
||||||
|
'--chip-accent': v['--gui-accent'],
|
||||||
|
}
|
||||||
|
return { id: p.id, name: p.name, vars: vars as CSSProperties }
|
||||||
|
})
|
||||||
|
|
||||||
|
type Props = { activeId: string; onPick: (id: string) => void }
|
||||||
|
|
||||||
|
/** The mod's built-in themes as starting points: a tiny window swatch plus the name. */
|
||||||
|
export function PresetPicker({ activeId, onPick }: Props) {
|
||||||
|
const { t } = useTranslation('themes')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul aria-label={t('editor.presets')} className="flex flex-wrap gap-1.5">
|
||||||
|
{CHIPS.map((chip) => (
|
||||||
|
<li key={chip.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(chip.id)}
|
||||||
|
aria-pressed={chip.id === activeId}
|
||||||
|
className="lv-preset-chip"
|
||||||
|
style={chip.vars}
|
||||||
|
>
|
||||||
|
<span className="lv-preset-mini" aria-hidden="true" />
|
||||||
|
{chip.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,22 @@ import { PRESETS } from '../../presets.generated'
|
||||||
import { ActiveThemeProvider, useActiveTheme } from '../../useActiveTheme'
|
import { ActiveThemeProvider, useActiveTheme } from '../../useActiveTheme'
|
||||||
import { ThemeEditor } from '../ThemeEditor'
|
import { ThemeEditor } from '../ThemeEditor'
|
||||||
|
|
||||||
test('editor shows the initial preset name and one toggle per gradient slot', () => {
|
test('editor shows the initial preset name and one switch per gradient slot behind the Gradients tab', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
render(<ThemeEditor initial={PRESETS[0]} />)
|
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
const name = screen.getByDisplayValue('Classic')
|
expect(screen.getByRole('textbox', { name: 'Название темы' })).toHaveValue('Classic')
|
||||||
expect(name).toBeInTheDocument()
|
expect(screen.queryAllByRole('switch')).toHaveLength(0)
|
||||||
expect(screen.getAllByRole('checkbox', { name: /Градиент/ })).toHaveLength(5)
|
await user.click(screen.getByRole('tab', { name: 'Градиенты' }))
|
||||||
|
expect(screen.getByRole('tab', { name: 'Градиенты' })).toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(screen.getAllByRole('switch', { name: /^Градиент: / })).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('section labels are plain sentence-case legends, not uppercase headings', () => {
|
||||||
|
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
|
const panel = screen.getByRole('region', { name: 'Настройки темы' })
|
||||||
|
expect(panel.querySelectorAll('h3')).toHaveLength(0)
|
||||||
|
expect(panel.querySelector('.uppercase')).toBeNull()
|
||||||
|
expect(within(panel).getByRole('group', { name: 'Окно' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('picking a preset chip loads it into the editor', () => {
|
test('picking a preset chip loads it into the editor', () => {
|
||||||
|
|
|
||||||
106
frontend/src/features/themes/editor/tests/panel.test.tsx
Normal file
106
frontend/src/features/themes/editor/tests/panel.test.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { expect, test, vi } from 'vitest'
|
||||||
|
import { PRESETS } from '../../presets.generated'
|
||||||
|
import { ThemeEditor } from '../ThemeEditor'
|
||||||
|
import { fromShareHash, toProfileJson } from '../model/codec'
|
||||||
|
|
||||||
|
const nameBox = () => screen.getByRole('textbox', { name: 'Название темы' })
|
||||||
|
|
||||||
|
test('undo and redo walk the history from the header buttons and from Ctrl+Z / Ctrl+Shift+Z', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
|
const undo = screen.getByRole('button', { name: 'Отменить' })
|
||||||
|
const redo = screen.getByRole('button', { name: 'Вернуть' })
|
||||||
|
expect(undo).toBeDisabled()
|
||||||
|
|
||||||
|
// A preset pick starts a fresh history; an edit (typing, one coalesced step) is what undo reverts.
|
||||||
|
const edited = `${PRESETS[0].name} 2`
|
||||||
|
await user.type(nameBox(), ' 2')
|
||||||
|
expect(nameBox()).toHaveValue(edited)
|
||||||
|
expect(undo).toBeEnabled()
|
||||||
|
|
||||||
|
await user.click(undo)
|
||||||
|
expect(nameBox()).toHaveValue(PRESETS[0].name)
|
||||||
|
await user.click(redo)
|
||||||
|
expect(nameBox()).toHaveValue(edited)
|
||||||
|
|
||||||
|
await user.keyboard('{Control>}z{/Control}')
|
||||||
|
expect(nameBox()).toHaveValue(PRESETS[0].name)
|
||||||
|
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}')
|
||||||
|
expect(nameBox()).toHaveValue(edited)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reset names the theme it returns to and restores it', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
|
await user.type(nameBox(), ' edited')
|
||||||
|
await user.click(screen.getByRole('button', { name: `Сбросить до «${PRESETS[0].name}»` }))
|
||||||
|
expect(nameBox()).toHaveValue(PRESETS[0].name)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renaming the theme updates the exported name and id', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
|
await user.clear(nameBox())
|
||||||
|
await user.type(nameBox(), 'My Night')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
|
||||||
|
expect(JSON.parse(await navigator.clipboard.readText()).name).toBe('My Night')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('copy puts the profile JSON on the clipboard and the button confirms', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ThemeEditor initial={PRESETS[1]} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
|
||||||
|
expect(await navigator.clipboard.readText()).toBe(toProfileJson(PRESETS[1]))
|
||||||
|
expect(screen.getByRole('button', { name: 'Скопировано' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Скопировано')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('share copies a link whose hash decodes back to the draft', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ThemeEditor initial={PRESETS[4]} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Скопировать ссылку' }))
|
||||||
|
const link = await navigator.clipboard.readText()
|
||||||
|
const hash = /#t=(.+)$/.exec(link)?.[1] ?? ''
|
||||||
|
expect(fromShareHash(hash)).toMatchObject({ name: PRESETS[4].name, theme: PRESETS[4].theme })
|
||||||
|
expect(screen.getByRole('button', { name: 'Ссылка скопирована' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a blocked clipboard says what to do instead', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
|
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(new Error('denied'))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('Скачай .json')
|
||||||
|
expect(screen.getByRole('button', { name: 'Скопировать для мода' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('download saves the profile JSON as <id>.json', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const createUrl = vi.fn(() => 'blob:theme')
|
||||||
|
Object.assign(URL, { createObjectURL: createUrl, revokeObjectURL: vi.fn() })
|
||||||
|
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||||
|
expect(this.download).toBe(`${PRESETS[0].id}.json`)
|
||||||
|
})
|
||||||
|
render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Скачать .json' }))
|
||||||
|
expect(click).toHaveBeenCalledOnce()
|
||||||
|
const blob = (createUrl.mock.calls[0] as unknown as [Blob])[0]
|
||||||
|
expect(await blob.text()).toBe(toProfileJson(PRESETS[0]))
|
||||||
|
click.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loading a file imports a valid theme and explains a broken one', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(<ThemeEditor initial={PRESETS[0]} />)
|
||||||
|
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||||
|
|
||||||
|
await user.upload(input, new File([toProfileJson(PRESETS[5])], 'theme.json', { type: 'application/json' }))
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(`Загружена тема «${PRESETS[5].name}»`)
|
||||||
|
expect(nameBox()).toHaveValue(PRESETS[5].name)
|
||||||
|
|
||||||
|
await user.upload(input, new File(['not json'], 'broken.json', { type: 'application/json' }))
|
||||||
|
expect(await screen.findByText(/Это не тема LoVisual/)).toBeInTheDocument()
|
||||||
|
expect(nameBox()).toHaveValue(PRESETS[5].name)
|
||||||
|
})
|
||||||
|
|
@ -19,12 +19,20 @@ export const themesEn = {
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
title: 'Theme editor',
|
title: 'Theme editor',
|
||||||
lead: 'Build your own theme and bring it into the mod as a single file.',
|
lead: 'Start from any theme in the mod and change the colors: the menu and this panel repaint as you go.',
|
||||||
|
panel: 'Theme settings',
|
||||||
nameLabel: 'Theme name',
|
nameLabel: 'Theme name',
|
||||||
presets: 'Ready presets',
|
presets: 'Themes from the mod',
|
||||||
|
palette: 'What to edit',
|
||||||
colors: 'Colors',
|
colors: 'Colors',
|
||||||
gradients: 'Gradients',
|
gradients: 'Gradients',
|
||||||
enabled: 'Gradient',
|
group: {
|
||||||
|
window: 'Window',
|
||||||
|
modules: 'Modules',
|
||||||
|
text: 'Text and accent',
|
||||||
|
},
|
||||||
|
alpha: '{{name}}: opacity',
|
||||||
|
enabledFor: 'Gradient: {{name}}',
|
||||||
angle: 'Angle',
|
angle: 'Angle',
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
end: 'End',
|
end: 'End',
|
||||||
|
|
@ -38,12 +46,14 @@ export const themesEn = {
|
||||||
copy: 'Copy for the mod',
|
copy: 'Copy for the mod',
|
||||||
copied: 'Copied',
|
copied: 'Copied',
|
||||||
download: 'Download .json',
|
download: 'Download .json',
|
||||||
import: 'Import from file',
|
import: 'Load from file',
|
||||||
share: 'Share link',
|
imported: 'Loaded the {{name}} theme',
|
||||||
|
share: 'Copy link',
|
||||||
shared: 'Link copied',
|
shared: 'Link copied',
|
||||||
reset: 'Reset',
|
reset: 'Reset to {{name}}',
|
||||||
undo: 'Undo',
|
undo: 'Undo',
|
||||||
redo: 'Redo',
|
redo: 'Redo',
|
||||||
importError: 'Could not read the theme: the file is malformed.',
|
importError: "That isn't a LoVisual theme: the file can't be read. Use a .json downloaded from this editor.",
|
||||||
|
clipboardError: 'The browser blocked the clipboard. Download the .json and move the file instead.',
|
||||||
},
|
},
|
||||||
} satisfies Translation<typeof themesRu>
|
} satisfies Translation<typeof themesRu>
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,20 @@ export const themesRu = {
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
title: 'Редактор тем',
|
title: 'Редактор тем',
|
||||||
lead: 'Собери свою тему и перенеси её в мод одним файлом.',
|
lead: 'Бери любую тему мода за основу и крути цвета: меню и эта панель перекрашиваются сразу.',
|
||||||
|
panel: 'Настройки темы',
|
||||||
nameLabel: 'Название темы',
|
nameLabel: 'Название темы',
|
||||||
presets: 'Готовые пресеты',
|
presets: 'Темы мода',
|
||||||
|
palette: 'Что настраивать',
|
||||||
colors: 'Цвета',
|
colors: 'Цвета',
|
||||||
gradients: 'Градиенты',
|
gradients: 'Градиенты',
|
||||||
enabled: 'Градиент',
|
group: {
|
||||||
|
window: 'Окно',
|
||||||
|
modules: 'Модули',
|
||||||
|
text: 'Текст и акцент',
|
||||||
|
},
|
||||||
|
alpha: '{{name}}: прозрачность',
|
||||||
|
enabledFor: 'Градиент: {{name}}',
|
||||||
angle: 'Угол',
|
angle: 'Угол',
|
||||||
start: 'Начало',
|
start: 'Начало',
|
||||||
end: 'Конец',
|
end: 'Конец',
|
||||||
|
|
@ -35,12 +43,14 @@ export const themesRu = {
|
||||||
copy: 'Скопировать для мода',
|
copy: 'Скопировать для мода',
|
||||||
copied: 'Скопировано',
|
copied: 'Скопировано',
|
||||||
download: 'Скачать .json',
|
download: 'Скачать .json',
|
||||||
import: 'Импорт из файла',
|
import: 'Загрузить из файла',
|
||||||
share: 'Ссылка на тему',
|
imported: 'Загружена тема «{{name}}»',
|
||||||
|
share: 'Скопировать ссылку',
|
||||||
shared: 'Ссылка скопирована',
|
shared: 'Ссылка скопирована',
|
||||||
reset: 'Сбросить',
|
reset: 'Сбросить до «{{name}}»',
|
||||||
undo: 'Отменить',
|
undo: 'Отменить',
|
||||||
redo: 'Вернуть',
|
redo: 'Вернуть',
|
||||||
importError: 'Не удалось прочитать тему: файл испорчен.',
|
importError: 'Это не тема LoVisual: файл не читается. Нужен .json, скачанный из этого редактора.',
|
||||||
|
clipboardError: 'Браузер не дал доступ к буферу обмена. Скачай .json и перенеси файл.',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue