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}`}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue