Desktop: sidebar reader as short rows with a progress bar; one Refresh (top bar) instead of two; rolling-out tile lists versions on their own lines; cards without screenshots show live versions per store

This commit is contained in:
CRTkafa 2026-10-23 09:43:08 +08:00
parent c36ad87658
commit 83b8ce8869
6 changed files with 67 additions and 17 deletions

View file

@ -152,7 +152,17 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null;
return (
<article className="pf-card" style={style} aria-label={name} {...menu(() => appMenu(app, d, nav, { showIgnored }))}>
<div className={`pf-shots${shots.length ? '' : ' empty'}`} aria-hidden="true">
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} loading="lazy" />) : <AppIcon name={app.name} src={icon} className="pf-hero-icon" />}
{shots.length ? shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} loading="lazy" />) : <>
<AppIcon name={app.name} src={icon} className="pf-hero-icon" />
<div className="pf-live">
{(['ios', 'android'] as const).filter(k => (k === 'ios' ? ios : android)).map(k => {
const now = storeNow(all, k).now;
return <div key={k} className="pf-live-row"><StoreGlyph store={k} /><span className="pf-live-s">{k === 'ios' ? 'App Store' : 'Google Play'}</span>
<span className="num strong">{now?.version ?? '–'}</span>{now && <PhaseChip r={now} />}</div>;
})}
<span className="pf-live-note">{t(projectId ? 'pf.noShots' : 'pf.noShotsStore')}</span>
</div>
</>}
</div>
<div className="pf-body">
<div className="pf-head">
@ -402,21 +412,18 @@ export function Portfolio() {
</div>
<div className="actions">
{neverImported === 0 && <button className="btn" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length} title={t('action.importAppsHint')}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>}
<button className="btn btn-primary" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy} title={t('action.readStoresHint')}>
<Icon name="refresh" className={d.portfolioBusy === 'live' ? 'spin' : undefined} />{d.portfolioBusy === 'live' ? t('action.reading') : t('action.readStores')}
</button>
</div>
</div>
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm btn-primary" onClick={() => { d.readAll(true).catch(() => {}); }}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
<div className="kpis">
<Kpi icon="layers" label={t('kpi.apps')} value={rows.length} sub={t('kpi.appsSubStore', { ios: iosApps, android: androidApps, store: storeOnly })} />
<Kpi icon="check" label={t('kpi.live')} value={known ? liveCount : '–'} sub={known ? t('kpi.liveSub', { count: stores }) : notRead} />
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? notRead : rolling.length ? [...new Set(rolling.map(r => r.version))].map(v => `${v}: ${rolling.filter(r => r.version === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(', ')}`).join(' · ') : t('kpi.none')} />
<Kpi icon="rocket" label={t('kpi.rolling')} value={known ? rolling.length : '–'} sub={!known ? notRead : rolling.length ? <RollingLines rolling={rolling} /> : t('kpi.none')} />
<Kpi icon="clock" label={t('kpi.review')} value={known ? review.length : '–'} sub={!known ? notRead : review.length ? review.map(r => `${r.store === 'ios' ? 'iOS' : 'Android'} ${r.version}`).join(' · ') : t('kpi.none')} tone={review.length ? 'warn' : undefined} />
<Kpi icon="alert" label={t('kpi.attention')} value={att.length} sub={blockers ? t('kpi.blockers', { count: blockers }) : t('kpi.noBlockers')} tone={blockers ? 'bad' : att.length ? 'warn' : undefined} />
</div>
@ -505,3 +512,17 @@ export function Portfolio() {
</div>
);
}
/** The rolling-out tile: the first two versions on their own lines ("2.3.0 iOS 20% · Android 20%"), then "+N more"; all of
them in the tooltip. Never one long ellipsized line. */
function RollingLines({ rolling }: { rolling: StoreRelease[] }) {
const versions = [...new Set(rolling.map(r => r.version ?? '?'))];
const line = (v: string) => rolling.filter(r => (r.version ?? '?') === v).map(r => `${r.store === 'ios' ? 'iOS' : 'Android'}${r.fraction !== null ? ` ${share(r.fraction)}` : ''}`).join(' · ');
const all = versions.map(v => `${v} ${line(v)}`).join(' · ');
return (
<span className="k-lines" title={all}>
{versions.slice(0, 2).map(v => <span key={v} className="k-line"><b className="num">{v}</b> {line(v)}</span>)}
{versions.length > 2 && <span className="k-line muted">{t('kpi.more', { count: versions.length - 2 })}</span>}
</span>
);
}