desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case

This commit is contained in:
CRTkafa 2026-10-14 00:35:29 +08:00
parent 714352ec18
commit 3cea2af4e1
37 changed files with 3020 additions and 54 deletions

View file

@ -0,0 +1,57 @@
// 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 type { EditableResult, Project } from '../../api';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
import { Icon, StoreGlyph } from '../../ui/icons';
import { Card, Empty } from '../../ui/kit';
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 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 <Card title={t('ed.artTitle')} icon="image"><Empty compact illo="text" title={t('ed.artEmpty')}>{t('ed.artEmptyText')}</Empty></Card>;
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" id="art-editor" style={{ gap: 16 }}>
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}>
<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 }}>
<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>
</Card>
<EditBar project={project} area="art" data={data} files={[data.files.config]} 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>
{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>
</Card>
</div>
);
}