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

@ -10,7 +10,7 @@
import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import { api, type EditableResult, type EditResult, type Project, type ToolResult } from '../api';
import { t } from '../i18n';
import { type EditArea, type EditOp, edits, keyOf } from '../lib/edits';
import { type EditArea, type EditOp, edits, type FieldOp, keyOf } from '../lib/edits';
import { failureText } from '../lib/results';
import { useRead, useWrite } from '../store';
import { Icon, type IconName } from './icons';
@ -61,7 +61,7 @@ interface FieldBase { project: Project; area: EditArea; label: string; hint?: Re
/** A text field: one line or several, a live character counter against the store limit. */
export function TextField({ project, area, op, saved, label, limit, multiline, rows = 3, placeholder, hint, issues = [], mono, id, disabled, kind }: FieldBase & {
/** The edit without its value: { kind: 'listing', store, lang, field } or { kind: 'config', path } or contact. */
op: Omit<Extract<EditOp, { kind: 'listing' }>, 'value'> | { kind: 'config'; path: string } | { kind: 'contact'; field: Extract<EditOp, { kind: 'contact' }>['field'] };
op: FieldOp;
saved: string; limit?: number; multiline?: boolean; rows?: number; placeholder?: string; mono?: boolean; kind?: 'url' | 'email' | 'text';
}) {
const e = useEdits();
@ -263,9 +263,10 @@ export function EditBar({ project, area, data, files, onSaved }: { project: Proj
}
/** A store write with its dry run in the window first: the plan (old -> new per store and language), then Apply. */
export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked }: {
export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked, onApplied }: {
project: Project; method: string; args?: Record<string, unknown>; label: string; icon?: IconName; done: string; disabled?: boolean; hint?: string;
/** Unsaved edits that the store write would not include. */ blocked?: string | null;
/** After the real run went through. */ onApplied?: () => void;
}) {
const w = useWrite(project.id);
const [st, setSt] = useState<{ loading: boolean; text: string; ok: boolean } | null>(null);
@ -289,7 +290,7 @@ export function ApplyPanel({ project, method, args = {}, label, icon = 'upload',
<pre className={`selectable ed-dry${st.ok ? '' : ' bad'}`}>{st.text}</pre>
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" onClick={() => setSt(null)}>{t('ed.close')}</button>
{st.ok && <WriteButton run={w} primary icon={icon} disabled={!!blocked} hint={blocked ?? undefined} w={{ method, args, title: label, done, after: () => setSt(null) }}>{t('ed.applyNow')}</WriteButton>}
{st.ok && <WriteButton run={w} primary icon={icon} disabled={!!blocked} hint={blocked ?? undefined} w={{ method, args, title: label, done, after: () => { setSt(null); onApplied?.(); } }}>{t('ed.applyNow')}</WriteButton>}
</div>
</div>
)}