Desktop v2 design system (design.css tokens for both themes, ui/ds.tsx: PageHeader, Hero, Section, StatTile, Pipeline, StatusPill, EmptyState, Toolbar, Tabs, Dialog, tint and screenshot hooks), app shell: sidebar status dots, frosted top bar, app page Hero with screenshots and the two-store pipeline, pill tabs with blocker counts
This commit is contained in:
parent
62366d95a7
commit
bd316723f5
6 changed files with 559 additions and 172 deletions
|
|
@ -1,17 +1,18 @@
|
|||
// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no
|
||||
// app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and
|
||||
// "Start an app folder here" to make it a full app.
|
||||
import { type KeyboardEvent, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { api, errorText } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withStudio, withUnpair } from '../lib/identity';
|
||||
import { storeNow } from '../lib/model';
|
||||
import { attention, storeNow } from '../lib/model';
|
||||
import { Hero, nextStep, Tabs } from '../ui/ds';
|
||||
import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav';
|
||||
import { useData } from '../store';
|
||||
import { Icon } from '../ui/icons';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { FolderMissing } from '../ui/FolderMissing';
|
||||
import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
|
||||
import { Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
|
||||
import { AppInfoTab } from './tabs/AppInfo';
|
||||
import { ArtTab } from './tabs/Art';
|
||||
import { LiveArt } from './tabs/LiveArt';
|
||||
|
|
@ -106,56 +107,40 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
const tabs = tabsFor(!!app.project);
|
||||
const ed = useEdits();
|
||||
useUnsavedGuard();
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const studio = d.studios.find(s => s.id === app.studio) ?? null;
|
||||
const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x });
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
const i = tabs.indexOf(tab);
|
||||
const j = ev.key === 'ArrowRight' ? i + 1 : ev.key === 'ArrowLeft' ? i - 1 : ev.key === 'Home' ? 0 : ev.key === 'End' ? tabs.length - 1 : null;
|
||||
if (j === null) return;
|
||||
ev.preventDefault();
|
||||
const n = (j + tabs.length) % tabs.length;
|
||||
setTab(tabs[n]!);
|
||||
tabRefs.current[n]?.focus();
|
||||
};
|
||||
const all = l?.release.all ?? [];
|
||||
const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null;
|
||||
const att = attention(app.key, app.name, e, l);
|
||||
const open = (url: string) => { api.openExternal(url).catch(() => {}); };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph">
|
||||
<div className="app-head">
|
||||
<AppIcon name={name} src={d.iconFor(app)} />
|
||||
<div>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<h1>{name}</h1>
|
||||
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
|
||||
{!app.project && <StoreOnlyTag />}
|
||||
<button className="studio-pill" onClick={() => nav.go({ screen: 'accounts', section: app.studio ?? 'none' })} title={t('app.studioHint')}><StudioMark studio={studio} size="sm" />{studio?.name ?? t('studio.none')}</button>
|
||||
</div>
|
||||
<div className="ids">
|
||||
<StoreBadge store="ios" id={ios?.bundleId} off={!ios && (!!e || !app.project)}>{iosNow && <><span className="num strong">{iosNow.version}</span><PhaseChip r={iosNow} /></>}</StoreBadge>
|
||||
<StoreBadge store="android" id={android?.packageName} off={!android && (!!e || !app.project)}>{androidNow && <><span className="num strong">{androidNow.version}</span><PhaseChip r={androidNow} /></>}</StoreBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{ios && <button className="btn btn-ghost" onClick={() => open(ascUrl(ios.ascAppId))} title={t('app.consoleHint')}><Icon name="external" />App Store Connect</button>}
|
||||
{android && <button className="btn btn-ghost" onClick={() => open(PLAY_CONSOLE)} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page ds-app">
|
||||
<Hero name={name} icon={d.iconFor(app)} projectId={app.project && !app.project.missing ? app.project.id : null}
|
||||
badges={<>
|
||||
{e?.summary?.profile && app.project && <Chip tone="outline">{t(`profile.${e.summary.profile}` as 'profile.app')}</Chip>}
|
||||
{!app.project && <StoreOnlyTag />}
|
||||
<button className="studio-pill" onClick={() => nav.go({ screen: 'accounts', section: app.studio ?? 'none' })} title={t('app.studioHint')}><StudioMark studio={studio} size="sm" />{studio?.name ?? t('studio.none')}</button>
|
||||
</>}
|
||||
ids={<>
|
||||
<StoreBadge store="ios" id={ios?.bundleId} off={!ios && (!!e || !app.project)}>{iosNow && <><span className="num strong">{iosNow.version}</span><PhaseChip r={iosNow} /></>}</StoreBadge>
|
||||
<StoreBadge store="android" id={android?.packageName} off={!android && (!!e || !app.project)}>{androidNow && <><span className="num strong">{androidNow.version}</span><PhaseChip r={androidNow} /></>}</StoreBadge>
|
||||
</>}
|
||||
links={(ios || android) && <>
|
||||
{ios && <button className="btn btn-sm btn-ghost" onClick={() => open(ascUrl(ios.ascAppId))} title={t('app.consoleHint')}><Icon name="external" />App Store Connect</button>}
|
||||
{android && <button className="btn btn-sm btn-ghost" onClick={() => open(PLAY_CONSOLE)} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
||||
</>}
|
||||
all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null}
|
||||
cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} />
|
||||
<AppControls app={app} />
|
||||
{!app.project && <StoreOnlyBanner app={app} />}
|
||||
{app.project?.storeboundPin && <Notice tone="info" icon="info">{t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}</Notice>}
|
||||
{app.project?.missing ? <FolderMissing project={app.project} /> : <>
|
||||
<div className="tabs" role="tablist" aria-label={t('app.sections', { name })} onKeyDown={onKey}>
|
||||
{tabs.map((x, i) => (
|
||||
<button key={x} ref={el => { tabRefs.current[i] = el; }} role="tab" id={`tab-${x}`} aria-selected={tab === x} aria-controls="tabpanel" tabIndex={tab === x ? 0 : -1} className="tab" onClick={() => setTab(x)}>
|
||||
<Icon name={TAB_ICON[x]} />{t(TAB_KEY[x])}
|
||||
{EDIT_TABS[x] && ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) > 0 && <span className="ed-dot" title={t('ed.unsaved', { count: ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) })} />}
|
||||
</button>
|
||||
))}
|
||||
<div className="ds-app-sticky">
|
||||
<Tabs label={t('app.sections', { name })} value={tab} onChange={setTab} items={tabs.map(x => {
|
||||
const n = EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0;
|
||||
return { id: x, label: t(TAB_KEY[x]), icon: TAB_ICON[x], count: att.filter(a => a.tab === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined };
|
||||
})} />
|
||||
</div>
|
||||
<div role="tabpanel" id="tabpanel" aria-labelledby={`tab-${tab}`} className="stack" style={{ gap: 16 }}>
|
||||
<ErrorBoundary key={tab} where={`tab ${tab}`}>
|
||||
|
|
|
|||
|
|
@ -4,18 +4,20 @@
|
|||
// version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first.
|
||||
import { plainError } from '../../../../cli/lib/google-errors';
|
||||
import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError';
|
||||
import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { api, type AppStatus, newJob, reads, type RenderImage, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago, pct, share } from '../lib/format';
|
||||
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity';
|
||||
import { type Attention, attention, healthSeries, type HealthSnapshot, hueOf, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model';
|
||||
import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model';
|
||||
import { type AppTab, useNav } from '../nav';
|
||||
import type { IconName } from '../ui/icons';
|
||||
import { useData } from '../store';
|
||||
import { exportName, portfolioCsv } from '../lib/exports';
|
||||
import { useExports } from '../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { nextStep, Pipeline, tintStyle, useShots, useTint } from '../ui/ds';
|
||||
export { nextStep } from '../ui/ds';
|
||||
import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit';
|
||||
|
||||
/** Tag of one app's offline config check. */
|
||||
|
|
@ -111,126 +113,6 @@ function releasesOf(l: AppStatus | undefined): StoreRelease[] {
|
|||
return l.release.all ?? [l.release.current?.ios, l.release.current?.android, l.release.next?.ios, l.release.next?.android].filter((x): x is StoreRelease => !!x);
|
||||
}
|
||||
|
||||
// Up to three phone screenshots of the app folder's rendered store art (`shots gallery`, then one `project.render`
|
||||
// data: URL each), loaded once per window and one app at a time. No renders: an empty list (the card shows the icon).
|
||||
const shotCache = new Map<string, Promise<string[]>>();
|
||||
let shotChain: Promise<unknown> = Promise.resolve();
|
||||
function loadShots(projectId: string): Promise<string[]> {
|
||||
const hit = shotCache.get(projectId);
|
||||
if (hit) return hit;
|
||||
const job = shotChain.then(async () => {
|
||||
const r = await reads.renders(projectId).catch(() => null);
|
||||
const res = r?.data;
|
||||
if (!res) return [];
|
||||
const v = res.variants.find(x => !x.id) ?? res.variants[0];
|
||||
const lang = v?.langs.find(l => l.lang === 'en' || l.lang.startsWith('en-')) ?? v?.langs[0];
|
||||
const rows = (lang?.rows ?? []).filter(row => !/feature|ipad|preview|tv|watch|mac|tablet/i.test(row.size) && row.tiles.some(x => x.kind === 'image'));
|
||||
rows.sort((a, b) => (a.store === 'apple' ? 0 : 1) - (b.store === 'apple' ? 0 : 1));
|
||||
const files = (rows[0]?.tiles ?? []).filter(x => x.kind === 'image' && x.h >= x.w).slice(0, 3).map(x => x.src);
|
||||
const out: string[] = [];
|
||||
for (const file of files) {
|
||||
const img = await api.call<ToolResult & { data?: RenderImage }>('project.render', projectId, { args: { file } }).catch(() => null);
|
||||
if (img?.data?.dataUrl) out.push(img.data.dataUrl);
|
||||
}
|
||||
return out;
|
||||
}).catch(() => [] as string[]);
|
||||
shotChain = job;
|
||||
shotCache.set(projectId, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
function useShots(projectId: string | null): string[] {
|
||||
const [shots, setShots] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
let alive = true;
|
||||
loadShots(projectId).then(s => { if (alive) setShots(s); }, () => {});
|
||||
return () => { alive = false; };
|
||||
}, [projectId]);
|
||||
return shots;
|
||||
}
|
||||
|
||||
// The hue of an app icon (saturation-weighted mean colour), for the card's soft gradient; the name's hue without one.
|
||||
const tintCache = new Map<string, number>();
|
||||
function useTint(src: string | null, name: string): number {
|
||||
const fallback = hueOf(name);
|
||||
const [hue, setHue] = useState<number>(() => (src ? tintCache.get(src) : undefined) ?? fallback);
|
||||
useEffect(() => {
|
||||
if (!src) { setHue(fallback); return; }
|
||||
const cached = tintCache.get(src);
|
||||
if (cached !== undefined) { setHue(cached); return; }
|
||||
let alive = true;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 16;
|
||||
const g = c.getContext('2d');
|
||||
if (!g) return;
|
||||
g.drawImage(img, 0, 0, 16, 16);
|
||||
const px = g.getImageData(0, 0, 16, 16).data;
|
||||
let r = 0, gr = 0, b = 0, w = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const R = px[i]!, G = px[i + 1]!, B = px[i + 2]!, A = px[i + 3]! / 255;
|
||||
const s = (Math.max(R, G, B) - Math.min(R, G, B)) / 255 * A;
|
||||
r += R * s; gr += G * s; b += B * s; w += s;
|
||||
}
|
||||
if (w < 0.5) return;
|
||||
r /= w; gr /= w; b /= w;
|
||||
const mx = Math.max(r, gr, b), mn = Math.min(r, gr, b), d = mx - mn;
|
||||
if (d < 1) return;
|
||||
let h = mx === r ? ((gr - b) / d) % 6 : mx === gr ? (b - r) / d + 2 : (r - gr) / d + 4;
|
||||
h = Math.round(h * 60 + 360) % 360;
|
||||
tintCache.set(src, h);
|
||||
if (alive) setHue(h);
|
||||
} catch { /* keep the name's hue */ }
|
||||
};
|
||||
img.src = src;
|
||||
return () => { alive = false; };
|
||||
}, [src, fallback]);
|
||||
return hue;
|
||||
}
|
||||
|
||||
interface Cta { text: string; tab: AppTab; icon: IconName; primary: boolean }
|
||||
|
||||
/** The one next step of an app, from its state: blockers first, then the release that waits for a move. */
|
||||
export function nextStep(o: { project: boolean; att: Attention[]; all: StoreRelease[]; next: string | null }): Cta {
|
||||
const bad = o.att.filter(a => a.tone === 'bad');
|
||||
if (bad.length) return { text: t('pf.ctaFix', { count: bad.length }), tab: bad[0]!.tab ?? 'checks', icon: 'alertCircle', primary: true };
|
||||
const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1);
|
||||
if (rolling) return { text: t('pf.ctaRollout', { share: share(rolling.fraction ?? 0) }), tab: 'releases', icon: 'rocket', primary: true };
|
||||
const approved = o.all.find(r => r.phase === 'approved');
|
||||
if (approved) return { text: t('pf.ctaRelease', { version: approved.version ?? '' }), tab: 'releases', icon: 'rocket', primary: true };
|
||||
const ready = o.all.find(r => r.phase === 'draft' || r.phase === 'testing');
|
||||
if (ready) return { text: t('pf.ctaSubmit', { version: ready.version ?? '' }), tab: 'releases', icon: 'send', primary: true };
|
||||
const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent');
|
||||
if (review) return { text: t('pf.ctaReview', { version: review.version ?? '' }), tab: 'releases', icon: 'clock', primary: false };
|
||||
if (o.att.length) return { text: t('pf.ctaWarnings', { count: o.att.length }), tab: o.att[0]!.tab ?? 'checks', icon: 'alert', primary: false };
|
||||
if (o.project && !o.next) return { text: t('pf.ctaUpload'), tab: 'releases', icon: 'upload', primary: false };
|
||||
return { text: t('pf.ctaOpen'), tab: 'releases', icon: 'arrowRight', primary: false };
|
||||
}
|
||||
|
||||
function Track({ store, all, on }: { store: 'ios' | 'android'; all: StoreRelease[]; on: boolean }) {
|
||||
const { now, next } = storeNow(all, store);
|
||||
const r = next ?? now;
|
||||
const lane = laneOf(store, on ? r : null);
|
||||
const name = store === 'ios' ? 'App Store' : 'Google Play';
|
||||
const state = !on ? t('app.notOnStore') : !r ? t('portfolio.noVersion') : `${r.version ?? ''} · ${t(`phase.${r.phase}` as Key)}${lane.share !== null && lane.share < 1 ? ` ${share(lane.share)}` : ''}`;
|
||||
return (
|
||||
<div className={`pf-track${on ? '' : ' off'}`} role="img" aria-label={`${name}: ${state}`}>
|
||||
<span className="pf-track-name"><StoreGlyph store={store} className="ic13" />{store === 'ios' ? 'App Store' : 'Play'}</span>
|
||||
<span className="pf-segs" aria-hidden="true">
|
||||
{STAGES.map((s, i) => {
|
||||
const cls = i < lane.at ? 'done' : i === lane.at ? `at t-${lane.tone}` : '';
|
||||
const fill = i === lane.at && s === 'rollout' && lane.share !== null && lane.share < 1 ? lane.share : null;
|
||||
return <b key={s} className={cls}>{fill !== null && <i style={{ width: `${Math.max(8, fill * 100)}%` }} />}</b>;
|
||||
})}
|
||||
</span>
|
||||
<span className="pf-track-v num" title={state}>{on && r ? `${r.version ?? ''}${lane.share !== null && lane.share < 1 ? ` · ${share(lane.share)}` : ''}` : '–'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHealth({ c, android }: { c: { h: HealthSnapshot; at: string } | null | undefined; android: boolean }) {
|
||||
const play = c?.h.play;
|
||||
if (!android || !c || !play || isError(play)) {
|
||||
|
|
@ -260,7 +142,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
|
|||
const cta = nextStep({ project: !!app.project, att, all, next });
|
||||
const name = e?.app?.name && app.project ? e.app.name : app.name;
|
||||
const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab });
|
||||
const style = { '--tint-h': String(hue) } as CSSProperties;
|
||||
const style = tintStyle(hue);
|
||||
return (
|
||||
<article className="pf-card" style={style} aria-label={name}>
|
||||
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
|
||||
|
|
@ -279,11 +161,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
|
|||
</div>
|
||||
</div>
|
||||
{studio}
|
||||
<div className="pf-pipe">
|
||||
<div className="pf-stages" aria-hidden="true"><span />{STAGES.map(s => <span key={s}>{t(`stage.${s}`)}</span>)}<span /></div>
|
||||
<Track store="ios" all={all} on={ios} />
|
||||
<Track store="android" all={all} on={android} />
|
||||
</div>
|
||||
<Pipeline all={all} ios={ios} android={android} />
|
||||
<div className="pf-foot">
|
||||
<CardHealth c={health} android={android} />
|
||||
<button className={`btn ${cta.primary ? 'btn-primary' : ''} pf-cta`} onClick={() => go(cta.tab)}><Icon name={cta.icon} />{cta.text}</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue