store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest

This commit is contained in:
CRTkafa 2026-10-14 00:53:38 +08:00
parent 3cea2af4e1
commit 7ac028421a
33 changed files with 852 additions and 157 deletions

View file

@ -1,6 +1,7 @@
// 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 { useState } from 'react';
import type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
@ -44,7 +45,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
))}
</ol>
</Card>
<EditBar project={project} area="art" data={data} files={[data.files.config]} onSaved={onSaved} />
{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} />
<Card 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>
@ -55,3 +57,35 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
</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 (
<Card 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>
</Card>
);
}