LoVisual/frontend/src/features/themes/editor/panel/ExportActions.tsx
loki5512344 c2c7e8fa91
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
2026-09-30 10:28:59 +02:00

108 lines
4 KiB
TypeScript

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>
)
}