store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59
This commit is contained in:
parent
49a73c90e4
commit
00be838b76
40 changed files with 1669 additions and 58 deletions
|
|
@ -49,6 +49,7 @@
|
|||
"allow-project-import-fastlane",
|
||||
"allow-export-save",
|
||||
"allow-price-sheet-pick",
|
||||
"allow-image-pick",
|
||||
"allow-project-reveal",
|
||||
"allow-project-locate",
|
||||
"allow-state-reset",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-image-pick"
|
||||
description = "Enables the image_pick command without any pre-configured scope."
|
||||
commands.allow = ["image_pick"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-image-pick"
|
||||
description = "Denies the image_pick command without any pre-configured scope."
|
||||
commands.deny = ["image_pick"]
|
||||
|
|
@ -712,6 +712,25 @@ pub async fn price_sheet_pick(window: WebviewWindow, state: State<'_, AppState>,
|
|||
Ok(Some(p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// An image (PNG / JPEG) to replace one store image with, picked in a native dialog. None = cancelled. The path goes
|
||||
/// back to the window only as the `from` argument of art.replace (a write: dry run, dialog, then the copy into the app
|
||||
/// folder).
|
||||
#[tauri::command]
|
||||
pub async fn image_pick(window: WebviewWindow, state: State<'_, AppState>, project_id: String) -> Res<Option<String>> {
|
||||
guard(&window)?;
|
||||
let start = saved(&state)?.projects.iter().find(|p| p.id == project_id).map(|p| PathBuf::from(&p.path)).ok_or_else(|| Fail::host("unknown project"))?;
|
||||
let w = window.clone();
|
||||
let picked = tauri::async_runtime::spawn_blocking(move || w.app_handle().dialog().file().set_title("Choose an image (PNG or JPEG)").add_filter("Image", &["png", "jpg", "jpeg"]).set_directory(start).set_parent(&w).blocking_pick_file())
|
||||
.await
|
||||
.map_err(|e| Fail::host(e.to_string()))?;
|
||||
let Some(picked) = picked else { return Ok(None) };
|
||||
let p = picked.into_path().map_err(|e| Fail::host(e.to_string()))?;
|
||||
if !p.extension().is_some_and(|e| ["png", "jpg", "jpeg"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
|
||||
return Err(Fail::host("pick a .png or .jpg file"));
|
||||
}
|
||||
Ok(Some(p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// Shows a folder or file of an app folder in the file manager (`rel` empty: the app folder), or opens it with
|
||||
/// `open` (a folder, or the gallery page storebound writes: <renders>/index.html in the default browser).
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ pub const COMMANDS: &[&str] = &[
|
|||
"project_import_fastlane",
|
||||
"export_save",
|
||||
"price_sheet_pick",
|
||||
"image_pick",
|
||||
"project_reveal",
|
||||
"project_locate",
|
||||
"state_reset",
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ pub fn run() {
|
|||
commands::project_import_fastlane,
|
||||
commands::export_save,
|
||||
commands::price_sheet_pick,
|
||||
commands::image_pick,
|
||||
commands::project_reveal,
|
||||
commands::project_locate,
|
||||
commands::state_reset,
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ pub fn store_only_write(method: &str, kind: Option<Kind>) -> bool {
|
|||
kind == Some(Kind::Write) && STORE_ONLY_WRITES.contains(&method)
|
||||
}
|
||||
|
||||
/// Reads that are never kept in the offline cache (big: one rendered image as a data: URL).
|
||||
pub const NO_CACHE_METHODS: &[&str] = &["project.render"];
|
||||
/// Reads that are never kept in the offline cache (big: one rendered image or one live store thumbnail as a data: URL).
|
||||
pub const NO_CACHE_METHODS: &[&str] = &["project.render", "art.thumb"];
|
||||
|
||||
/// File kinds the window may export through `export_save`, with their extension.
|
||||
pub const EXPORT_KINDS: &[&str] = &["csv", "md", "json", "txt"];
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ fn app_folder_paths() {
|
|||
#[test]
|
||||
fn new_folder_methods_and_big_reads() {
|
||||
assert_eq!(NEW_FOLDER_METHODS, &["project.init", "import.fastlane"]);
|
||||
assert_eq!(NO_CACHE_METHODS, &["project.render"]);
|
||||
for c in ["project_import_fastlane", "export_save", "price_sheet_pick", "project_reveal"] {
|
||||
assert_eq!(NO_CACHE_METHODS, &["project.render", "art.thumb"]);
|
||||
for c in ["project_import_fastlane", "export_save", "price_sheet_pick", "image_pick", "project_reveal"] {
|
||||
assert!(COMMANDS.contains(&c), "{c}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type {
|
|||
ProgressParams, ProjectSummary, ReleasePhase, ReleaseTrain, ReplyDraft, StoreRelease, ToolResult,
|
||||
EditableListing, EditableResult, EditResult,
|
||||
AnalyticsCompare, AsoAuditView, AsoTrendRow, ConsoleAnswer, ConsoleFillResult, ConsoleForm, DeclAsk, DeclarationsResult, ListingText, PpoStatus, PpoTest, PriceDiffRow, PriceRowData, PricesCurrent, PricesDiff, RenderImage, RenderTile, RendersResult,
|
||||
ArtThumb, LiveArtResult, LiveArtSet, LiveArtTile,
|
||||
} from '../../../cli/lib/desktop-protocol';
|
||||
|
||||
export interface AppInfo { version: string; identifier: string; platform: string; debug: boolean; home: string | null }
|
||||
|
|
@ -128,6 +129,8 @@ export const api = {
|
|||
exportSave: (name: string, kind: ExportKind, content: string, personal = false) => invoke<string | null>('export_save', { name, kind, content, personal }),
|
||||
/** Native open dialog for a price sheet (CSV); its path, or null. */
|
||||
pickPriceSheet: (projectId: string) => invoke<string | null>('price_sheet_pick', { projectId }),
|
||||
/** Native open dialog for an image (PNG / JPEG) to replace one store image with; its path, or null. */
|
||||
pickImage: (projectId: string) => invoke<string | null>('image_pick', { projectId }),
|
||||
/** Shows a path of an app folder in the file manager, or opens a folder / the gallery page. */
|
||||
reveal: (projectId: string, rel = '', open = false) => invoke<void>('project_reveal', { projectId, rel, open }),
|
||||
/** "Locate…" for a folder that is gone: native folder picker; the same app with its new path, or null. */
|
||||
|
|
|
|||
|
|
@ -1098,9 +1098,37 @@ export const en = {
|
|||
'art.prev': 'Previous',
|
||||
'art.next': 'Next',
|
||||
'art.emptyTitle': 'No renders yet',
|
||||
'art.emptyText': 'Render the store screenshots from the frames and the copy file: press Render. It runs locally (ffmpeg and Chrome) and uploads nothing.',
|
||||
'art.emptyText': 'An app that is already live: Pull from stores below saves the live art here. A new app: render the store screenshots from the frames and the copy file with Render (locally, ffmpeg and Chrome; nothing is uploaded).',
|
||||
'art.noIssues': 'No issues here',
|
||||
'art.noIssuesText': 'Every file of this language passes the local store checks.',
|
||||
'art.liveTitle': 'Live on the stores',
|
||||
'art.liveRead': 'Read the stores',
|
||||
'art.liveEmpty': 'Live store art not read yet',
|
||||
'art.liveEmptyText': 'Reads the screenshots, previews, feature graphic and icon that are live on App Store Connect and Google Play. Read-only.',
|
||||
'art.liveCount': '{count} images',
|
||||
'art.liveCount_one': '1 image',
|
||||
'art.liveFailed': 'could not read',
|
||||
'art.liveReadOnly': 'A store-only app: the live art is shown read-only. Add the app folder to pull it into the project and edit it.',
|
||||
'art.liveSummary': '{missing} store images are not in the app folder yet, {differs} differ from your files. Pull from stores adds the missing ones and keeps yours.',
|
||||
'art.liveAllSame': 'The app folder has every live image, the same as on the stores: uploads send nothing.',
|
||||
'art.liveStore': 'Store',
|
||||
'art.liveNone': 'No store art yet',
|
||||
'art.liveNoneText': 'The stores have no screenshots for this app yet.',
|
||||
'art.liveNoThumb': 'no preview',
|
||||
'art.liveSame': 'same as the store',
|
||||
'art.liveDiffers': 'yours differs',
|
||||
'art.liveMissing': 'not pulled yet',
|
||||
'art.liveFoot': 'Read-only on the stores. Pull from stores saves the originals into the renders folder (pulled.json), so checks and uploads treat them as the store\'s own.',
|
||||
'art.liveFootStoreOnly': 'Read-only on the stores.',
|
||||
'art.iconApple': 'App Store icon',
|
||||
'art.iconPlay': 'Play icon',
|
||||
'art.pull': 'Pull from stores',
|
||||
'art.pulled': 'Store art pulled into the app folder',
|
||||
'art.pullHint': 'Downloads the live screenshots, previews, feature graphic and icons into the app folder (dry run first). Your own files that differ are kept.',
|
||||
'art.replace': 'Replace with a file',
|
||||
'art.replaceTitle': 'Replace {name}',
|
||||
'art.replaced': 'Image replaced in the app folder',
|
||||
'ed.frameLang': 'Frames of',
|
||||
'art.foot': 'Local files only, read {when}. Uploading is on the Listings tab.',
|
||||
|
||||
'gr.analytics': 'Store analytics',
|
||||
|
|
|
|||
|
|
@ -1100,9 +1100,37 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'art.prev': 'Önceki',
|
||||
'art.next': 'Sonraki',
|
||||
'art.emptyTitle': 'Henüz görsel yok',
|
||||
'art.emptyText': 'Mağaza ekran görüntülerini karelerden ve metin dosyasından oluşturun: Oluştur\'a basın. Yerelde çalışır (ffmpeg ve Chrome) ve hiçbir şey yüklemez.',
|
||||
'art.emptyText': 'Zaten yayında olan bir uygulama: aşağıdaki Mağazalardan çek yayındaki görselleri buraya kaydeder. Yeni bir uygulama: ekran görüntülerini karelerden ve metin dosyasından Oluştur ile oluşturun (yerelde, ffmpeg ve Chrome; hiçbir şey yüklenmez).',
|
||||
'art.noIssues': 'Burada sorun yok',
|
||||
'art.noIssuesText': 'Bu dilin her dosyası yerel mağaza kontrollerinden geçiyor.',
|
||||
'art.liveTitle': 'Mağazalarda yayında',
|
||||
'art.liveRead': 'Mağazaları oku',
|
||||
'art.liveEmpty': 'Yayındaki görseller henüz okunmadı',
|
||||
'art.liveEmptyText': 'App Store Connect ve Google Play\'de yayında olan ekran görüntülerini, önizlemeleri, öne çıkan görseli ve simgeyi okur. Yalnızca okur.',
|
||||
'art.liveCount': '{count} görsel',
|
||||
'art.liveCount_one': '1 görsel',
|
||||
'art.liveFailed': 'okunamadı',
|
||||
'art.liveReadOnly': 'Yalnızca mağazada olan bir uygulama: yayındaki görseller salt okunur gösterilir. Projeye çekmek ve düzenlemek için uygulama klasörünü ekleyin.',
|
||||
'art.liveSummary': '{missing} mağaza görseli henüz uygulama klasöründe yok, {differs} tanesi sizin dosyalarınızdan farklı. Mağazalardan çek eksikleri ekler, sizinkileri korur.',
|
||||
'art.liveAllSame': 'Uygulama klasöründe yayındaki her görsel mağazadakiyle aynı: yüklemeler hiçbir şey göndermez.',
|
||||
'art.liveStore': 'Mağaza',
|
||||
'art.liveNone': 'Henüz mağaza görseli yok',
|
||||
'art.liveNoneText': 'Mağazalarda bu uygulamanın henüz ekran görüntüsü yok.',
|
||||
'art.liveNoThumb': 'önizleme yok',
|
||||
'art.liveSame': 'mağazadakiyle aynı',
|
||||
'art.liveDiffers': 'sizinki farklı',
|
||||
'art.liveMissing': 'henüz çekilmedi',
|
||||
'art.liveFoot': 'Mağazalarda yalnızca okur. Mağazalardan çek, orijinalleri renders klasörüne kaydeder (pulled.json); denetimler ve yüklemeler onları mağazanın kendi dosyası sayar.',
|
||||
'art.liveFootStoreOnly': 'Mağazalarda yalnızca okur.',
|
||||
'art.iconApple': 'App Store simgesi',
|
||||
'art.iconPlay': 'Play simgesi',
|
||||
'art.pull': 'Mağazalardan çek',
|
||||
'art.pulled': 'Mağaza görselleri uygulama klasörüne çekildi',
|
||||
'art.pullHint': 'Yayındaki ekran görüntülerini, önizlemeleri, öne çıkan görseli ve simgeleri uygulama klasörüne indirir (önce deneme). Farklı olan kendi dosyalarınız korunur.',
|
||||
'art.replace': 'Dosyayla değiştir',
|
||||
'art.replaceTitle': '{name} değiştir',
|
||||
'art.replaced': 'Görsel uygulama klasöründe değiştirildi',
|
||||
'ed.frameLang': 'Kareler',
|
||||
'art.foot': 'Yalnızca yerel dosyalar, {when} okundu. Yükleme Mağaza metinleri sekmesinde.',
|
||||
|
||||
'gr.analytics': 'Mağaza analizleri',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const TTL: Record<string, number> = {
|
|||
'project.listings': 30 * MIN,
|
||||
'project.editable': 30 * MIN,
|
||||
'listing.live': 30 * MIN,
|
||||
'art.live': 6 * 60 * MIN,
|
||||
'apple.ppo_status': 30 * MIN,
|
||||
validate: 30 * MIN,
|
||||
scan: 30 * MIN,
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import type { IconName } from './ui/icons';
|
|||
|
||||
export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks'] as const;
|
||||
export type AppTab = (typeof APP_TABS)[number];
|
||||
/** Tabs of a store-only app: store reads only (catalog, prices, listings, art, app info and checks need the app folder;
|
||||
* Growth shows the Product Page Optimization tests only). */
|
||||
export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'health', 'reviews', 'testflight', 'growth'];
|
||||
/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and
|
||||
* store art are the live ones; Growth shows the Product Page Optimization tests only). */
|
||||
export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth'];
|
||||
export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS);
|
||||
export const TAB_KEY: Record<AppTab, Key> = {
|
||||
releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { FolderMissing } from '../ui/FolderMissing';
|
|||
import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
|
||||
import { AppInfoTab } from './tabs/AppInfo';
|
||||
import { ArtTab } from './tabs/Art';
|
||||
import { LiveArt } from './tabs/LiveArt';
|
||||
import { CatalogTab } from './tabs/Catalog';
|
||||
import { ChecksTab } from './tabs/Checks';
|
||||
import { HealthTab } from './tabs/Health';
|
||||
|
|
@ -163,6 +164,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
{tab === 'listings' && app.project && <><TabEditor tab={tab} project={app.project} /><ListingsTab project={app.project} /></>}
|
||||
{tab === 'listings' && !app.project && <StoreListingEditor app={app} />}
|
||||
{tab === 'art' && app.project && <ArtTab project={app.project} />}
|
||||
{tab === 'art' && !app.project && <LiveArt target={app.target} />}
|
||||
{tab === 'health' && <HealthTab app={app} />}
|
||||
{tab === 'reviews' && <ReviewsTab app={app} />}
|
||||
{tab === 'testflight' && <TestFlightTab app={app} />}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
// 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 { 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';
|
||||
|
|
@ -13,6 +14,20 @@ type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Rec
|
|||
|
||||
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));
|
||||
|
|
@ -28,7 +43,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
};
|
||||
return (
|
||||
<div className="stack" id="art-editor" style={{ gap: 16 }}>
|
||||
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}>
|
||||
<Card 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}>
|
||||
|
|
@ -38,6 +54,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useData, useRead, useWrite } from '../../store';
|
|||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Kpi, Seg, Skeleton } from '../../ui/kit';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { LiveArt } from './LiveArt';
|
||||
|
||||
// loaded images by project + file + render time, three at a time
|
||||
const cache = new Map<string, string | null>();
|
||||
|
|
@ -68,7 +69,7 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re
|
|||
);
|
||||
}
|
||||
|
||||
function Lightbox({ projectId, tiles, index, stamp, onClose, onMove }: { projectId: string; tiles: RenderTile[]; index: number; stamp: string; onClose: () => void; onMove: (i: number) => void }) {
|
||||
function Lightbox({ projectId, tiles, index, stamp, onClose, onMove, onReplace }: { projectId: string; tiles: RenderTile[]; index: number; stamp: string; onClose: () => void; onMove: (i: number) => void; onReplace?: (tile: RenderTile) => void }) {
|
||||
const tile = tiles[index]!;
|
||||
const file = tile.kind === 'video' ? tile.poster : tile.src;
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
|
@ -87,6 +88,7 @@ function Lightbox({ projectId, tiles, index, stamp, onClose, onMove }: { project
|
|||
<div className="lb-foot">
|
||||
<div className="grow"><b className="mono-id">{tile.src}</b><div className="small muted">{tile.w}×{tile.h} · {bytes(tile.bytes)} · {num(index + 1)} / {num(tiles.length)}</div>
|
||||
{tile.issues.map((i, k) => <div key={k} className={`small ${i.level === 'error' ? 'bad-ink' : 'warn-ink'}`}>{i.msg}</div>)}</div>
|
||||
{onReplace && tile.kind === 'image' && <button className="btn btn-sm" onClick={() => onReplace(tile)} data-action="replace-image"><Icon name="upload" />{t('art.replace')}</button>}
|
||||
<button className="btn btn-sm" onClick={() => onMove((index - 1 + tiles.length) % tiles.length)} aria-label={t('art.prev')}><Icon name="arrowLeft" /></button>
|
||||
<button className="btn btn-sm" onClick={() => onMove((index + 1) % tiles.length)} aria-label={t('art.next')}><Icon name="arrowRight" /></button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={onClose}><Icon name="x" />{t('common.close')}</button>
|
||||
|
|
@ -113,8 +115,17 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
const dir = data?.dir ?? 'store/renders';
|
||||
const reveal = useCallback((rel: string, openIt = false) => { api.reveal(project.id, rel, openIt).catch(e => d.toast({ kind: 'info', title: t('art.notThere'), detail: errorText(e) })); }, [project.id, d]);
|
||||
const stamp = data?.generated ?? '';
|
||||
// replace one image with a file the owner picks: native open dialog, then art.replace (dry run with the store
|
||||
// checks, the native dialog, the copy into the app folder)
|
||||
const replace = useCallback(async (tile: RenderTile) => {
|
||||
let from: string | null = null;
|
||||
try { from = await api.pickImage(project.id); } catch (e) { d.toast({ kind: 'bad', title: t('art.replace'), detail: errorText(e) }); return; }
|
||||
if (!from) return;
|
||||
await w.run({ method: 'art.replace', args: { from, to: `${data?.dir ?? 'store/renders'}/${tile.src}` }, title: t('art.replaceTitle', { name: tile.name }), done: t('art.replaced'), after: r.refresh });
|
||||
}, [project.id, d, w, data?.dir, r.refresh]);
|
||||
|
||||
if (!data) return r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>;
|
||||
// the live art stays the first child in both branches, so it keeps its state when the local renders arrive
|
||||
if (!data) return <><LiveArt target={project.id} project={project} />{r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>}</>;
|
||||
const total = data.totals.images + data.totals.videos;
|
||||
const toolbar = (
|
||||
<>
|
||||
|
|
@ -126,6 +137,7 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
);
|
||||
return (
|
||||
<>
|
||||
<LiveArt target={project.id} project={project} />
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
|
||||
<Kpi icon="image" label={t('art.images')} value={num(data.totals.images)} sub={t('art.imagesSub', { count: langs.length })} />
|
||||
<Kpi icon="play" label={t('art.videos')} value={num(data.totals.videos)} sub={t('art.videosSub')} />
|
||||
|
|
@ -162,7 +174,7 @@ export function ArtTab({ project }: { project: Project }) {
|
|||
)}
|
||||
<div className="card-foot"><Icon name="info" />{t('art.foot', { when: ago(r.at) })}</div>
|
||||
</Card>
|
||||
{open && <Lightbox projectId={project.id} tiles={open.tiles} index={open.i} stamp={stamp} onClose={() => setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} />}
|
||||
{open && <Lightbox projectId={project.id} tiles={open.tiles} index={open.i} stamp={stamp} onClose={() => setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
apps/desktop/src/screens/tabs/LiveArt.tsx
Normal file
133
apps/desktop/src/screens/tabs/LiveArt.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// The store art that is live on App Store Connect and Google Play (art.live), per store, language and set, with
|
||||
// thumbnails (art.thumb: one data: URL each, loaded as they scroll into view; the window loads no remote images
|
||||
// itself) and, for an app folder, whether the folder has the same file. "Pull from stores" runs art.pull (dry run,
|
||||
// native dialog, then the files are written: local only, read-only on the stores). Store-only apps: read-only.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type ArtThumb, type LiveArtResult, type LiveArtTile, type Project, type ToolResult } from '../../api';
|
||||
import { t } from '../../i18n';
|
||||
import { num } from '../../lib/format';
|
||||
import { useRead, useWrite } from '../../store';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
|
||||
// thumbnails by target + URL, three at a time
|
||||
const cache = new Map<string, string | null>();
|
||||
const waiting: (() => Promise<void>)[] = [];
|
||||
let active = 0;
|
||||
function pump() {
|
||||
while (active < 3 && waiting.length) {
|
||||
const job = waiting.shift()!;
|
||||
active++;
|
||||
job().finally(() => { active--; pump(); });
|
||||
}
|
||||
}
|
||||
export function loadThumb(target: string, url: string): Promise<string | null> {
|
||||
const key = `${target}|${url}`;
|
||||
if (cache.has(key)) return Promise.resolve(cache.get(key)!);
|
||||
return new Promise(resolve => {
|
||||
waiting.push(async () => {
|
||||
try {
|
||||
const r = await api.call<ToolResult & { data?: ArtThumb }>('art.thumb', target, { args: { url } });
|
||||
cache.set(key, r.data?.dataUrl ?? null);
|
||||
} catch { cache.set(key, null); }
|
||||
resolve(cache.get(key) ?? null);
|
||||
});
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
function LiveThumb({ target, tile, name }: { target: string; tile: LiveArtTile; name: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [src, setSrc] = useState<string | null | undefined>(tile.thumb ? cache.get(`${target}|${tile.thumb}`) : null);
|
||||
useEffect(() => {
|
||||
if (!tile.thumb || src !== undefined) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let alive = true;
|
||||
const io = new IntersectionObserver(es => {
|
||||
if (!es.some(x => x.isIntersecting)) return;
|
||||
io.disconnect();
|
||||
loadThumb(target, tile.thumb).then(u => { if (alive) setSrc(u); });
|
||||
}, { rootMargin: '300px' });
|
||||
io.observe(el);
|
||||
return () => { alive = false; io.disconnect(); };
|
||||
}, [target, tile.thumb, src]);
|
||||
const w = tile.w ?? 9, h = tile.h ?? 16;
|
||||
const state = tile.state === 'n/a' ? null : tile.state;
|
||||
return (
|
||||
<figure className={`art-tile${state === 'differs' ? ' warn' : ''}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
|
||||
<div className="art-pic" ref={ref} role="img" aria-label={name}>
|
||||
{src ? <img src={src} alt="" draggable={false} /> : <span className="art-ph">{src === null && tile.thumb ? <><Icon name="image" />{t('art.liveNoThumb')}</> : <span className="sk sk-block" />}</span>}
|
||||
</div>
|
||||
<figcaption><span className="mono-id">{name}</span><span className="muted">{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}</span></figcaption>
|
||||
{state && <span className={`small ${state === 'same' ? 'ok-ink' : state === 'differs' ? 'warn-ink' : 'muted'}`}>{t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}</span>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live store art of one app: `target` is the project id, or a store-only app's store target. */
|
||||
export function LiveArt({ target, project }: { target: string; project?: Project }) {
|
||||
const r = useRead<LiveArtResult>(target, 'art.live', { network: true, title: t('art.liveTitle') });
|
||||
const w = useWrite(project?.id ?? null);
|
||||
const [store, setStore] = useState<'apple' | 'play' | ''>('');
|
||||
const [locale, setLocale] = useState('');
|
||||
const data = r.data;
|
||||
const stores = useMemo(() => [...new Set((data?.sets ?? []).map(s => s.store))], [data]);
|
||||
const st = stores.includes(store as 'apple') ? store : stores[0] ?? '';
|
||||
const locales = useMemo(() => [...new Set((data?.sets ?? []).filter(s => s.store === st).map(s => s.locale))].sort(), [data, st]);
|
||||
const loc = locales.includes(locale) ? locale : locales[0] ?? '';
|
||||
const sets = (data?.sets ?? []).filter(s => s.store === st && s.locale === loc);
|
||||
const pull = project ? (
|
||||
<WriteButton run={w} primary icon="download" w={{ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }} hint={t('art.pullHint')}>{t('art.pull')}</WriteButton>
|
||||
) : null;
|
||||
const actions = <>{pull}<ReadControl r={r} /></>;
|
||||
if (!data) {
|
||||
return (
|
||||
<Card title={t('art.liveTitle')} icon="cloud" actions={actions} id="live-art" pad={false}>
|
||||
{r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} block /></div>
|
||||
: <Empty compact illo="text" title={t('art.liveEmpty')} actions={<button className="btn btn-primary btn-sm" onClick={() => { r.refresh(); }}><Icon name="refresh" />{t('art.liveRead')}</button>}>{r.error ?? t('art.liveEmptyText')}</Empty>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const total = data.sets.reduce((n, s) => n + s.tiles.length, 0);
|
||||
const differs = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'differs').length, 0);
|
||||
const missing = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'missing').length, 0);
|
||||
const sub = data.stores.map(s => `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.ok ? t('art.liveCount', { count: s.images }) : t('art.liveFailed')}${s.version ? ` · ${s.version}` : ''}`).join(' · ');
|
||||
return (
|
||||
<Card title={t('art.liveTitle')} icon="cloud" sub={sub} actions={actions} id="live-art" pad={false}>
|
||||
{data.stores.filter(s => !s.ok).map(s => <div className="card-body" key={s.store}><ErrorNotice error={`${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.error ?? ''}`} /></div>)}
|
||||
<div className="card-body stack" style={{ gap: 10 }}>
|
||||
{!project && <Notice tone="info" icon="info">{t('art.liveReadOnly')}</Notice>}
|
||||
{project && total > 0 && (missing > 0 || differs > 0) && <Notice tone={differs ? 'warn' : 'info'} icon="info">{t('art.liveSummary', { missing: num(missing), differs: num(differs) })}</Notice>}
|
||||
{project && total > 0 && !missing && !differs && <Notice tone="ok" icon="check">{t('art.liveAllSame')}</Notice>}
|
||||
{data.icons.length > 0 && (
|
||||
<div className="row" style={{ gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }} data-live-icons>
|
||||
{data.icons.map(i => <div key={i.store} style={{ width: 96 }}><LiveThumb target={target} tile={{ ...i, w: 1, h: 1 }} name={i.store === 'apple' ? t('art.iconApple') : t('art.iconPlay')} /></div>)}
|
||||
</div>
|
||||
)}
|
||||
{stores.length > 1 && (
|
||||
<div className="row" style={{ gap: 8 }} role="tablist" aria-label={t('art.liveStore')}>
|
||||
{stores.map(s => <button key={s} role="tab" aria-selected={s === st} className={`btn btn-sm${s === st ? ' btn-primary' : ''}`} onClick={() => setStore(s)}>{s === 'apple' ? 'App Store' : 'Google Play'}</button>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="fchips" role="group" aria-label={t('col.language')}>
|
||||
{locales.map(l => <button key={l} className="fchip" aria-pressed={l === loc} onClick={() => setLocale(l)} data-live-locale={l}>{l}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
{!total ? <Empty compact illo="text" title={t('art.liveNone')}>{t('art.liveNoneText')}</Empty> : (
|
||||
<div className="art-rows">
|
||||
{sets.map(s => (
|
||||
<section className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
|
||||
<h3><span>{s.label}</span>{s.lang && <code>{s.lang}/{s.size}</code>}<Chip tone="outline" dot={false}>{s.store === 'apple' ? 'App Store' : 'Google Play'}</Chip><span className="muted small">{t('art.count', { count: s.tiles.length })}</span></h3>
|
||||
<div className="art-tiles" style={{ ['--tw' as string]: s.size === 'feature' ? '300px' : s.tiles[0] && (s.tiles[0].w ?? 0) > (s.tiles[0].h ?? 1) ? '260px' : '150px' }}>
|
||||
{s.tiles.map(tile => <LiveThumb key={`${tile.index}-${tile.thumb}`} target={target} tile={tile} name={String(tile.index + 1).padStart(2, '0')} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-foot"><Icon name="info" />{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -838,7 +838,7 @@ textarea.input { height: auto; padding: 8px 10px; line-height: 1.45; resize: ver
|
|||
.err-foot .grow { display: grid; gap: 4px; }
|
||||
.err-foot .fix-hint { color: var(--ink-2); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.bad-ink { color: var(--bad-ink); } .warn-ink { color: var(--warn-ink); }
|
||||
.bad-ink { color: var(--bad-ink); } .warn-ink { color: var(--warn-ink); } .ok-ink { color: var(--ok-ink); }
|
||||
|
||||
/* prices */
|
||||
.px-matrix td { vertical-align: top; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue