193 lines
11 KiB
TypeScript
193 lines
11 KiB
TypeScript
// Shared UI pieces of the design system (styles.css): chips, store badges, app icons, KPI tiles, bars, sparklines
|
|
// (visx, charts.tsx), stars (Phosphor), notices, skeletons, empty states, segmented controls, cards.
|
|
import { StarIcon } from '@phosphor-icons/react/dist/csr/Star';
|
|
import type { ReactNode } from 'react';
|
|
import type { ReleasePhase, StoreRelease } from '../api';
|
|
import { t } from '../i18n';
|
|
import { ago, share } from '../lib/format';
|
|
import { hueOf, monogram, type Tone } from '../lib/model';
|
|
import { fixHint } from '../lib/views';
|
|
import { Icon, type IconName, StoreGlyph } from './icons';
|
|
import { Illo, type IlloName } from './illustrations';
|
|
|
|
export function Chip({ tone = 'neutral', children, dot = true, title, icon }: { tone?: Tone | 'outline'; children: ReactNode; dot?: boolean; title?: string; icon?: IconName }) {
|
|
return <span className={`chip ${tone}`} title={title}>{icon ? <Icon name={icon} /> : dot && tone !== 'outline' && <i />}{children}</span>;
|
|
}
|
|
|
|
const PHASE_TONE: Record<ReleasePhase, Tone> = { live: 'ok', rollout: 'accent', halted: 'bad', released: 'info', approved: 'ok', review: 'warn', sent: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' };
|
|
|
|
/** Chip of a store release: "Live", "Rollout 20%", "In review" ... (iOS phased release: "Phased 50%", "Paused"). */
|
|
export function phaseLabel(r: StoreRelease | null | undefined): { text: string; tone: Tone } {
|
|
if (!r) return { text: t('phase.none'), tone: 'neutral' };
|
|
const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : '';
|
|
if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' };
|
|
if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' };
|
|
const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : '';
|
|
if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' };
|
|
if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent'))
|
|
return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.review')) + stale, tone: 'warn' };
|
|
if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' };
|
|
if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' };
|
|
return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] };
|
|
}
|
|
|
|
export function PhaseChip({ r }: { r: StoreRelease | null | undefined }) {
|
|
const p = phaseLabel(r);
|
|
return <Chip tone={p.tone} title={r?.state}>{p.text}</Chip>;
|
|
}
|
|
|
|
export const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play');
|
|
|
|
/** "App Store · com.example.app" badge, dashed when the app is not on that store. */
|
|
export function StoreBadge({ store, id, off, children }: { store: 'ios' | 'android'; id?: string | null; off?: boolean; children?: ReactNode }) {
|
|
return (
|
|
<span className={`store-badge${off ? ' off' : ''}`} title={off ? t('app.notSetUp') : id ?? undefined}>
|
|
<StoreGlyph store={store} className={store === 'ios' ? 'glyph-ios' : 'glyph-play'} />
|
|
{storeName(store)}
|
|
{id && <><span className="sb-sep" /><span className="mono-id">{id}</span></>}
|
|
{off && <span className="muted">· {t('app.notSetUp')}</span>}
|
|
{children && <><span className="sb-sep" />{children}</>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** The app's own icon when the project has one, else a monogram on a gradient picked from the name. */
|
|
export function AppIcon({ name, src, className = '' }: { name: string; src?: string | null; className?: string }) {
|
|
if (src) return <span className={`app-icon ${className}`}><img src={src} alt="" draggable={false} /></span>;
|
|
const h = hueOf(name);
|
|
return (
|
|
<span className={`app-icon mono ${className}`} style={{ background: `linear-gradient(135deg, hsl(${h} 72% 58%), hsl(${(h + 40) % 360} 70% 44%))` }} aria-hidden="true">
|
|
{monogram(name)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Kpi({ label, value, sub, icon, tone }: { label: string; value: ReactNode; sub?: ReactNode; icon?: IconName; tone?: Tone }) {
|
|
return (
|
|
<div className={`kpi${tone ? ` tone-${tone}` : ''}`}>
|
|
<div className="k-label">{icon && <Icon name={icon} />}{label}</div>
|
|
<div className="k-value">{value}</div>
|
|
{sub !== undefined && <div className="k-sub">{sub}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Bar({ value, tone, thin, label }: { value: number; tone?: Tone; thin?: boolean; label?: string }) {
|
|
const v = Math.max(0, Math.min(1, value));
|
|
return (
|
|
<div className={`bar${tone && tone !== 'accent' ? ` ${tone}` : ''}${thin ? ' thin' : ''}`} role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(v * 100)} aria-label={label}>
|
|
<span style={{ width: `${v * 100}%` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Crash / ANR sparkline (visx): see charts.tsx. */
|
|
export { Sparkline } from './charts';
|
|
|
|
export function Stars({ n, label }: { n: number; label?: string }) {
|
|
return (
|
|
<span className="stars" role="img" aria-label={label ?? t('reviews.starsAria', { count: n })}>
|
|
{[1, 2, 3, 4, 5].map(i => <StarIcon key={i} className={i <= n ? '' : 'off'} weight="fill" aria-hidden="true" />)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function Notice({ tone, children, icon, action }: { tone: 'bad' | 'warn' | 'ok' | 'info'; children: ReactNode; icon?: IconName; action?: ReactNode }) {
|
|
const ic: IconName = icon ?? (tone === 'bad' ? 'alertCircle' : tone === 'warn' ? 'alert' : tone === 'ok' ? 'check' : 'info');
|
|
return <div className={`notice ${tone}`} role={tone === 'bad' ? 'alert' : 'status'}><Icon name={ic} weight="fill" /><div className="grow">{children}</div>{action}</div>;
|
|
}
|
|
|
|
/** A failed read or write in words, with how to fix it when the error is a known one ("errors that say how to fix"). */
|
|
export function ErrorNotice({ error, tone = 'warn', keep, action }: { error: string; tone?: 'bad' | 'warn'; keep?: boolean; action?: ReactNode }) {
|
|
const hint = fixHint(error);
|
|
return (
|
|
<Notice tone={tone} action={action}>
|
|
<div className="stack" style={{ gap: 4 }}>
|
|
<span>{keep ? t('common.readFailedKeep', { error }) : error}</span>
|
|
{hint && <span className="fix-hint"><Icon name="wand" />{t(hint)}</span>}
|
|
</div>
|
|
</Notice>
|
|
);
|
|
}
|
|
|
|
/** A card footer with a failed read and, when known, how to fix it. */
|
|
export function ErrorFoot({ error }: { error: string }) {
|
|
const hint = fixHint(error);
|
|
return <div className="card-foot err-foot"><Icon name="alertCircle" /><span className="grow">{error}{hint && <span className="fix-hint"><Icon name="wand" />{t(hint)}</span>}</span></div>;
|
|
}
|
|
|
|
export function Skeleton({ lines = 3, block }: { lines?: number; block?: boolean }) {
|
|
return (
|
|
<div className="stack" aria-busy="true" aria-label={t('common.loading')}>
|
|
{block && <span className="sk sk-block" />}
|
|
{Array.from({ length: lines }, (_, i) => <span key={i} className="sk sk-line" style={{ width: `${[92, 76, 84, 58, 70][i % 5]}%` }} />)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Empty({ illo, title, children, actions, compact }: { illo: IlloName; title: string; children?: ReactNode; actions?: ReactNode; compact?: boolean }) {
|
|
return (
|
|
<div className={`empty${compact ? ' compact' : ''}`}>
|
|
<Illo name={illo} />
|
|
<h3>{title}</h3>
|
|
{children && <p>{children}</p>}
|
|
{actions && <div className="actions">{actions}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Seg<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string, IconName?][]; onChange: (v: T) => void; label: string }) {
|
|
return (
|
|
<div className="seg" role="group" aria-label={label}>
|
|
{options.map(([v, text, icon]) => <button key={v} type="button" aria-pressed={value === v} onClick={() => onChange(v)}>{icon && <Icon name={icon} />}{text}</button>)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Card({ title, icon, sub, actions, children, foot, className = '', pad = true, id }: { title?: ReactNode; icon?: IconName; sub?: ReactNode; actions?: ReactNode; children: ReactNode; foot?: ReactNode; className?: string; pad?: boolean; id?: string }) {
|
|
return (
|
|
<section className={`card ${className}`} id={id}>
|
|
{title !== undefined && (
|
|
<div className="card-head">
|
|
<div className="stack" style={{ gap: 2 }}><h2>{icon && <Icon name={icon} />}{title}</h2>{sub && <div className="sub">{sub}</div>}</div>
|
|
{actions && <div className="actions">{actions}</div>}
|
|
</div>
|
|
)}
|
|
{pad ? <div className="card-body">{children}</div> : children}
|
|
{foot && <div className="card-foot">{foot}</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function Kbd({ keys }: { keys: string[] }) {
|
|
return <>{keys.map(k => <span key={k} className="kbd">{k}</span>)}</>;
|
|
}
|
|
|
|
/** Platform modifier label (⌘ on macOS, Ctrl elsewhere). */
|
|
export const MOD = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';
|
|
|
|
/** A studio's logo, or its monogram on the studio colour. `null` studio: "No studio" (dashed). */
|
|
export function StudioMark({ studio, size = 'md' }: { studio: { name: string; color: string; logo?: string | null } | null; size?: 'sm' | 'md' | 'lg' }) {
|
|
if (!studio) return <span className={`studio-mark none ${size}`} aria-hidden="true" />;
|
|
if (studio.logo) return <span className={`studio-mark ${size}`} aria-hidden="true"><img src={studio.logo} alt="" draggable={false} /></span>;
|
|
return <span className={`studio-mark studio-${studio.color} ${size}`} aria-hidden="true">{monogram(studio.name)}</span>;
|
|
}
|
|
|
|
/** Small "store only" marker (an app without an app folder). */
|
|
export function StoreOnlyTag() {
|
|
return <span className="chip outline so-tag" title={t('apps.storeOnlyHint')}><Icon name="cloud" />{t('apps.storeOnly')}</span>;
|
|
}
|
|
|
|
/** The state of one read beside its card's title: "read 3 min ago" with a small spinner while it refreshes, or
|
|
* "couldn't refresh: <reason>" over the last good value, and the per-card refresh icon (no "Read" button first). */
|
|
export function ReadControl({ r, label }: { r: { data: unknown; at: string | null; loading: boolean; error: string | null; refresh: () => Promise<void> }; label?: string }) {
|
|
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
|
|
return (
|
|
<span className="read-ctl" data-read-state={r.loading ? 'loading' : r.error ? 'error' : r.data !== null ? 'ok' : 'none'}>
|
|
{text && <span className={`read-stamp ${r.error ? 'read-err' : ''}`} title={r.error ?? undefined}>{text}</span>}
|
|
<button className="btn btn-sm btn-icon" onClick={() => { r.refresh(); }} disabled={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
|
|
<Icon name="refresh" className={r.loading ? 'spin' : undefined} />
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|