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:
parent
c36ad87658
commit
83b8ce8869
6 changed files with 67 additions and 17 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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ç',
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue