storebound/apps/desktop/src/screens/editors/ArtEditor.tsx

110 lines
8 KiB
TypeScript

// Store art editor (Store art tab): the order of the screenshot scenes (the store shows them in this order) and the
// raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config;
// then render, and upload to each store with the dry run in the window (uploads replace the store's sets).
import '../../styles/art.css';
import { useState } from 'react';
import { api, errorText, type EditableResult, type Project } from '../../api';
import { useData, useWrite } from '../../store';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Empty } from '../../ui/kit';
import { Section } from '../../ui/ds';
import { keyOf } from '../../lib/edits';
type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Record<string, unknown>;
export function ArtEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const w = useWrite(project.id);
const d = useData();
const langs = data.languages.map(l => l.id);
const [frameLang, setFrameLang] = useState(langs[0] ?? '');
const fl = langs.includes(frameLang) ? frameLang : langs[0] ?? '';
const framesDir = ((data.config as unknown as { shots?: { frames?: string } | null }).shots?.frames ?? 'store/shots/frames').replace(/\/+$/, '');
// replace the frame a scene shows (this language, phone) with a file the owner picks: native open dialog, then
// art.replace (dry run with the checks, the native dialog, the copy into the app folder); render again afterwards
const replaceFrame = async (src: string) => {
let from: string | null = null;
try { from = await api.pickImage(project.id); } catch (err) { d.toast({ kind: 'bad', title: t('art.replace'), detail: errorText(err) }); return; }
if (!from) return;
await w.run({ method: 'art.replace', args: { from, to: `${framesDir}/${fl}/phone/${src}` }, title: t('art.replaceTitle', { name: src }), done: t('art.replaced') });
};
const saved = ((data.config as unknown as { shots?: { scenes?: Scene[] } | null }).shots?.scenes ?? []) as Scene[];
const op = { kind: 'config' as const, path: 'shots.scenes' };
const p = e.get(project.id, keyOf(op));
const scenes = (p && p.op.kind === 'config' ? p.op.value : saved) as Scene[];
const unsaved = e.count(project.id, 'art') ? t('ed.saveFirst') : null;
if (!saved.length) return <Section className="scr-art" title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Section>;
const move = (i: number, d: -1 | 1) => {
const next = [...scenes];
const j = i + d;
if (j < 0 || j >= next.length) return;
[next[i], next[j]] = [next[j]!, next[i]!];
e.set(project.id, { ...op, value: next }, 'art', saved);
};
return (
<div className="stack scr-art art-editor" id="art-editor" style={{ gap: 16 }}>
<Section title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}
actions={langs.length > 1 ? <label className="row small" style={{ gap: 6 }}>{t('ed.frameLang')}<select value={fl} onChange={ev => setFrameLang(ev.target.value)} data-frame-lang>{langs.map(l => <option key={l} value={l}>{l}</option>)}</select></label> : undefined}>
<ol className="ed-scenes">
{scenes.map((s, i) => (
<li key={s.id} data-scene={s.id}>
<span className="num strong">{i + 1}</span>
<span className="mono-id strong">{s.id}</span>
<div className="ed-scene-frame">
<TextField project={project} area="art" op={{ kind: 'config', path: `shots.scenes[id=${s.id}].frames[0].src` }} saved={saved.find(x => x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono />
</div>
<div className="row" style={{ gap: 4 }}>
{s.frames?.[0]?.src && <button className="btn btn-xs" disabled={!!w.busy} onClick={() => { replaceFrame(s.frames![0]!.src); }} title={`${framesDir}/${fl}/phone/${s.frames[0].src}`} data-action="replace-frame"><Icon name="upload" />{t('art.replace')}</button>}
<button className="btn btn-xs btn-ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label={t('ed.moveUp', { id: s.id })}><Icon name="arrowLeft" className="rot90" />{t('ed.up')}</button>
<button className="btn btn-xs btn-ghost" disabled={i === scenes.length - 1} onClick={() => move(i, 1)} aria-label={t('ed.moveDown', { id: s.id })}><Icon name="arrowRight" className="rot90" />{t('ed.down')}</button>
</div>
</li>
))}
</ol>
</Section>
{data.shotsCopy && <Captions project={project} data={data} scenes={scenes.map(s => s.id)} />}
<EditBar project={project} area="art" data={data} files={[data.files.config, data.files.shotsCopy ?? null]} onSaved={onSaved} />
<Section title={t('ed.applyTitle')} icon="upload" sub={t('ed.applyArtSub')}>
<div className="act-grid">
<div className="act-col"><div className="act-head"><Icon name="wand" />{t('act.render')}</div><ApplyPanel project={project} method="shots.render" label={t('act.renderShots')} done={t('act.rendered')} blocked={unsaved} /></div>
{data.play && <div className="act-col"><div className="act-head"><StoreGlyph store="android" />Google Play</div><ApplyPanel project={project} method="play.images" label={t('act.playImages')} done={t('act.uploaded')} blocked={unsaved} /></div>}
{data.apple && <div className="act-col"><div className="act-head"><StoreGlyph store="ios" />App Store</div><ApplyPanel project={project} method="apple.screenshots" label={t('act.appleShots')} done={t('act.uploaded')} blocked={unsaved} /><ApplyPanel project={project} method="apple.previews" label={t('act.applePreviews')} done={t('act.uploaded')} blocked={unsaved} /></div>}
</div>
</Section>
</div>
);
}
/** Screenshot captions per language and scene (the shots copy file): eyebrow, the two title lines, the subline. */
function Captions({ project, data, scenes }: { project: Project; data: EditableResult; scenes: string[] }) {
const copy = data.shotsCopy ?? {};
const langs = data.languages.map(l => l.id);
const [lang, setLang] = useState(langs[0] ?? '');
const L = langs.includes(lang) ? lang : langs[0] ?? '';
return (
<Section title={t('ed.captionsTitle')} icon="text" sub={t('ed.captionsSub', { file: data.files.shotsCopy ?? '' })} id="captions-editor">
<div className="row" style={{ gap: 8, flexWrap: 'wrap', marginBottom: 12 }} role="tablist" aria-label={t('col.language')}>
{langs.map(x => <button key={x} role="tab" aria-selected={x === L} className={`btn btn-sm${x === L ? ' btn-primary' : ''}`} onClick={() => setLang(x)}><Icon name="globe" />{x}</button>)}
</div>
<div className="ed-langs">
{scenes.map(id => {
const c = copy[L]?.[id] ?? {};
const title = Array.isArray(c.title) ? c.title : [];
return (
<div className="ed-lang" key={id} data-caption={id}>
<div className="small strong mono-id">{id}</div>
<div className="ed-grid">
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'eyebrow' }} saved={typeof c.eyebrow === 'string' ? c.eyebrow : ''} label={t('ed.eyebrow')} limit={40} />
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'title', line: 0 }} saved={title[0] ?? ''} label={t('ed.titleLine', { n: 1 })} limit={30} />
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'title', line: 1 }} saved={title[1] ?? ''} label={t('ed.titleLine', { n: 2 })} limit={30} />
<TextField project={project} area="art" op={{ kind: 'shotsCopy', lang: L, scene: id, field: 'sub' }} saved={typeof c.sub === 'string' ? c.sub : ''} label={t('ed.subline')} limit={90} />
</div>
</div>
);
})}
</div>
</Section>
);
}