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

@ -286,17 +286,22 @@ export function App({ onTheme }: { onTheme: () => void }) {
);
}
/** The background reader in one line: "Reading 12 of 48 · App Store limit 3 412/3 600 left this hour". */
/** The background reader in the sidebar foot: progress (count + thin bar) and the App Store hourly limit on their own
* short rows, so nothing is cut off; the long wording stays in the tooltip. */
function ReaderLine() {
const d = useData();
const r = d.reader;
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
const parts: string[] = [];
if (r.total > 0 && (r.running || r.queued)) parts.push(t('reader.progress', { done: r.done, total: r.total }));
else if (!d.reader.autoRead) parts.push(t('reader.off'));
else if (d.ready && d.apps.length) parts.push(t('reader.upToDate'));
if (Object.keys(r.paused).length) parts.push(t('reader.backoff'));
if (q) parts.push(t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) }));
if (!parts.length) return null;
return <div className="row reader-line" data-testid="reader-line" aria-live="polite"><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span title={parts.join(' · ')}>{parts.join(' · ')}</span></div>;
const busy = r.total > 0 && (r.running || r.queued);
const head = busy ? t('reader.progress', { done: r.done, total: r.total }) : !d.reader.autoRead ? t('reader.off') : d.ready && d.apps.length ? t('reader.upToDate') : '';
const paused = Object.keys(r.paused).length > 0;
if (!head && !q && !paused) return null;
return (
<div className="reader-line" data-testid="reader-line" aria-live="polite">
{head && <div className="row" title={head}><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span>{head}</span></div>}
{busy && <div className="reader-bar" role="progressbar" aria-valuemin={0} aria-valuemax={r.total} aria-valuenow={r.done} aria-label={head}><i style={{ width: `${Math.round((r.done / Math.max(1, r.total)) * 100)}%` }} /></div>}
{paused && <div className="row" title={t('reader.backoff')}><Icon name="pause" className="sr-icon" /><span>{t('reader.backoffShort')}</span></div>}
{q && <div className="row" title={t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) })}><Icon name="gauge" className="sr-icon" /><span>{t('reader.appleQuotaShort', { left: num(q.remaining), limit: num(q.limit) })}</span></div>}
</div>
);
}

View file

@ -64,6 +64,11 @@ export const en = {
'palette.prevApp': 'Previous app',
'settings.scApps': 'Previous / next app',
'settings.scMenu': 'Context menu of the focused item',
'reader.backoffShort': "Slowed down by the store",
'reader.appleQuotaShort': "App Store {left} / {limit} per hour",
'kpi.more': "+{count} more",
'pf.noShots': "No screenshots in the app folder yet",
'pf.noShotsStore': "Store only: no app folder with screenshots",
'palette.gActions': 'Actions',
'palette.openApp': 'Open',

View file

@ -66,6 +66,11 @@ export const tr: Record<keyof typeof en, string> = {
'palette.prevApp': 'Önceki uygulama',
'settings.scApps': 'Önceki / sonraki uygulama',
'settings.scMenu': 'Odaktaki öğenin bağlam menüsü',
'reader.backoffShort': "Mağaza yavaşlattı",
'reader.appleQuotaShort': "App Store saatte {left} / {limit}",
'kpi.more': "+{count} tane daha",
'pf.noShots': "Uygulama klasöründe henüz ekran görüntüsü yok",
'pf.noShotsStore': "Yalnız mağazada: ekran görüntülü uygulama klasörü yok",
'palette.gActions': 'İşlemler',
'palette.openApp': 'Aç',

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>
);
}

View file

@ -1048,7 +1048,7 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-
.pf-shots { display: flex; gap: 10px; height: 200px; padding: 14px 14px 0; overflow: hidden; background: linear-gradient(160deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .35), var(--shot-bg) 70%); -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent); mask-image: linear-gradient(90deg, #000 85%, transparent); }
.pf-shots img { height: 100%; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 12px 12px 0 0; box-shadow: 0 10px 24px -10px rgba(0, 0, 0, .45); flex: none; transition: transform var(--speed) var(--ease); }
.pf-card:hover .pf-shots img { transform: translateY(-3px); }
.pf-shots.empty { align-items: center; justify-content: center; padding: 0; -webkit-mask-image: none; mask-image: none;
.pf-shots.empty { align-items: center; justify-content: center; gap: 22px; padding: 0 22px; -webkit-mask-image: none; mask-image: none;
background: radial-gradient(60% 90% at 50% 60%, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .45), transparent 70%), linear-gradient(160deg, hsl(var(--tint-h) var(--pf-tint-s) var(--pf-tint-l) / .22), var(--shot-bg)); }
.pf-shots .pf-hero-icon { width: 96px; height: 96px; border-radius: 24px; font-size: 30px; box-shadow: 0 18px 40px -14px hsl(var(--tint-h) 70% 30% / .7), inset 0 0 0 1px rgba(255, 255, 255, .12); transition: transform var(--speed) var(--ease); }
.pf-card:hover .pf-hero-icon { transform: scale(1.04) rotate(-2deg); }
@ -1110,3 +1110,16 @@ button.ck.ck-pick[aria-checked="true"] { border-color: var(--accent-solid); box-
@media (max-width: 1100px) { .pf-cols { grid-template-columns: minmax(0, 1fr); } }
@media (prefers-reduced-motion: reduce) { .pf-card, .pf-card:hover, .pf-shots img, .pf-hero-icon { transform: none !important; transition: none; } }
:root[data-motion="reduce"] .pf-card, :root[data-motion="reduce"] .pf-card:hover, :root[data-motion="reduce"] .pf-shots img, :root[data-motion="reduce"] .pf-hero-icon { transform: none !important; transition: none; }
.reader-line { display: grid; gap: 5px; }
.reader-line .row span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reader-bar { height: 3px; border-radius: 2px; background: var(--line); overflow: hidden; margin-left: 21px; }
.reader-bar i { display: block; height: 100%; background: var(--accent-solid); transition: width var(--speed) var(--ease); }
.kpi .k-sub .k-lines { display: grid; gap: 1px; white-space: nowrap; }
.kpi .k-sub .k-line { overflow: hidden; text-overflow: ellipsis; }
.kpi .k-sub:has(.k-lines) { white-space: normal; overflow: visible; }
.pf-shots.empty .pf-hero-icon { width: 76px; height: 76px; border-radius: 20px; flex: none; }
.pf-live { display: grid; gap: 8px; min-width: 0; }
.pf-live-row { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; border-radius: 9px; background: color-mix(in srgb, var(--surface) 70%, transparent); border: 1px solid var(--line); font-size: var(--t-meta); white-space: nowrap; }
.pf-live-row svg, .pf-live-row .store-mark { width: 14px; height: 14px; flex: none; }
.pf-live-s { color: var(--muted); }
.pf-live-note { font-size: var(--t-micro, 11px); color: var(--muted); }

View file

@ -27,6 +27,7 @@ import { DotIcon } from '@phosphor-icons/react/dist/csr/Dot';
import { DownloadSimpleIcon } from '@phosphor-icons/react/dist/csr/DownloadSimple';
import { ExportIcon } from '@phosphor-icons/react/dist/csr/Export';
import { DotsThreeIcon } from '@phosphor-icons/react/dist/csr/DotsThree';
import { GaugeIcon } from '@phosphor-icons/react/dist/csr/Gauge';
import { EyeIcon } from '@phosphor-icons/react/dist/csr/Eye';
import { EyeSlashIcon } from '@phosphor-icons/react/dist/csr/EyeSlash';
import { FileTextIcon } from '@phosphor-icons/react/dist/csr/FileText';
@ -85,7 +86,7 @@ const ICONS = {
sun: SunIcon, terminal: TerminalWindowIcon, copy: CopyIcon, image: ImageIcon, users: UsersIcon, layers: StackIcon,
sparkle: SparkleIcon, pause: PauseIcon, cube: CubeIcon, arrowRight: ArrowRightIcon, arrowLeft: ArrowLeftIcon, dot: DotIcon,
phone: DeviceMobileIcon, keyboard: KeyboardIcon, database: DatabaseIcon, wand: MagicWandIcon, building: BuildingsIcon,
more: DotsThreeIcon, eye: EyeIcon, eyeOff: EyeSlashIcon, link: LinkIcon, unlink: LinkBreakIcon, bell: BellIcon, filter: FunnelIcon, play: PlayIcon,
more: DotsThreeIcon, gauge: GaugeIcon, eye: EyeIcon, eyeOff: EyeSlashIcon, link: LinkIcon, unlink: LinkBreakIcon, bell: BellIcon, filter: FunnelIcon, play: PlayIcon,
send: PaperPlaneRightIcon, edit: PencilSimpleIcon, upload: UploadSimpleIcon, sliders: SlidersHorizontalIcon,
rotate: ArrowCounterClockwiseIcon, cloud: CloudIcon, coins: CoinsIcon, chart: TrendUpIcon, flask: FlaskIcon, grid: GridFourIcon,
clipboard: ClipboardTextIcon, share: ExportIcon, lock: LockIcon, question: QuestionIcon,