Desktop: tray mini card on left click (store status at a glance next to the tray icon; Refresh runs the main window's Refresh all)
This commit is contained in:
parent
c03938f9dc
commit
60a72e6947
23 changed files with 1052 additions and 11 deletions
156
apps/desktop/src/card/Card.tsx
Normal file
156
apps/desktop/src/card/Card.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// The tray card: store status at a glance next to the tray icon. It only draws the main window's snapshot (card_get /
|
||||
// `card-snapshot`) and sends its buttons back (card_action); Refresh is the main window's own Refresh all.
|
||||
import { ArrowsClockwise, ArrowSquareOut, BellSlash, Bell, DotsThree, Power, Star, WarningOctagon, Pulse, AppWindow } from '@phosphor-icons/react';
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import type { CardApp, CardRelease, CardSnapshot, CardStore } from './model';
|
||||
import { ago, cardT, type CardKey, type CardLang } from './text';
|
||||
|
||||
export interface CardHost {
|
||||
get: () => Promise<CardSnapshot | null>;
|
||||
onSnapshot: (fn: (s: CardSnapshot) => void) => () => void;
|
||||
onShown: (fn: () => void) => () => void;
|
||||
action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready', arg?: string) => void;
|
||||
}
|
||||
|
||||
const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab });
|
||||
|
||||
export function Card({ host, lang }: { host: CardHost; lang: CardLang }) {
|
||||
const T = useCallback((k: CardKey, v?: Record<string, string | number>) => cardT(lang, k, v), [lang]);
|
||||
const [s, setS] = useState<CardSnapshot | null>(null);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [shown, setShown] = useState(0);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
host.get().then(x => { if (x) setS(x); }).catch(() => {});
|
||||
const a = host.onSnapshot(setS);
|
||||
const b = host.onShown(() => { setNow(Date.now()); setMenu(false); setShown(n => n + 1); });
|
||||
host.action('ready');
|
||||
const tm = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => { a(); b(); clearInterval(tm); };
|
||||
}, [host]);
|
||||
// every show: fade in, focus the first control (keyboard users start at Refresh)
|
||||
useEffect(() => { (root.current?.querySelector<HTMLElement>('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]);
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); if (menu) setMenu(false); else host.action('hide'); return; }
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const all = [...(root.current?.querySelectorAll<HTMLElement>('button:not([disabled])') ?? [])];
|
||||
const i = all.indexOf(document.activeElement as HTMLElement);
|
||||
const n = all[(i + (e.key === 'ArrowDown' ? 1 : -1) + all.length) % all.length];
|
||||
if (n) { e.preventDefault(); n.focus(); }
|
||||
}
|
||||
};
|
||||
|
||||
const store = (st: CardStore) => T(st === 'ios' ? 'appStore' : 'playStore');
|
||||
const phase = (r: CardRelease) => (r.phase === 'rollout' ? (r.fraction != null && r.fraction < 1 ? T('rolloutPct', { pct: Math.round(r.fraction * 100) }) : T('rollout')) : T(r.phase));
|
||||
const link = (key: string): CardApp | undefined => s?.links.find(l => l.key === key);
|
||||
const busy = !!s?.busy;
|
||||
const calm = s && !s.rejections.length && !s.releases.length && !s.health.length && !s.reviews.low.length;
|
||||
|
||||
return (
|
||||
<div className="tc" ref={root} onKeyDown={onKey} data-shown={shown} tabIndex={-1} role="dialog" aria-label={T('title')}>
|
||||
<header className="tc-head">
|
||||
<div className="tc-title">
|
||||
<strong>{T('title')}</strong>
|
||||
<span className="tc-meta">{s?.muted && <span className="tc-muted"><BellSlash size={12} weight="bold" />{T('muted')}</span>}{T('lastRead', { ago: ago(lang, s?.at ?? null, now) })}</span>
|
||||
</div>
|
||||
<button className="tc-btn tc-primary" data-first onClick={() => host.action('refresh')} disabled={busy} aria-busy={busy} data-testid="card-refresh">
|
||||
<ArrowsClockwise size={14} weight="bold" className={busy ? 'spin' : undefined} />{busy ? T('refreshing') : T('refresh')}
|
||||
</button>
|
||||
<button className="tc-icon" onClick={() => setMenu(m => !m)} aria-haspopup="menu" aria-expanded={menu} aria-label={T('more')} title={T('more')}><DotsThree size={18} weight="bold" /></button>
|
||||
{menu && (
|
||||
<div className="tc-menu" role="menu">
|
||||
<button role="menuitem" onClick={() => host.action('open')}><AppWindow size={15} />{T('openMain')}</button>
|
||||
<button role="menuitem" onClick={() => { host.action('mute'); setMenu(false); }}>{s?.muted ? <Bell size={15} /> : <BellSlash size={15} />}{s?.muted ? T('unmute') : T('mute')}</button>
|
||||
<hr />
|
||||
<button role="menuitem" onClick={() => host.action('quit')}><Power size={15} />{T('quit')}</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="tc-body">
|
||||
{!s && <p className="tc-empty">{T('noData')}</p>}
|
||||
{s && calm && <p className="tc-empty">{s.at ? T('calm') : T('noData')}</p>}
|
||||
|
||||
{!!s?.rejections.length && (
|
||||
<Section title={T('rejections')} tone="bad">
|
||||
{s.rejections.map(r => (
|
||||
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone="bad" onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
|
||||
line={<><WarningOctagon size={13} weight="fill" />{T('rejected')} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>} sub={r.reason}
|
||||
consoles={consoles(link(r.app), r.store, host, T)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!!s?.releases.length && (
|
||||
<Section title={T('moving')}>
|
||||
{s.releases.map(r => (
|
||||
<Row key={`${r.app}:${r.store}:${r.version}`} hue={r.hue} name={r.name} tone={r.phase === 'halted' ? 'bad' : r.phase === 'approved' ? 'ok' : r.phase === 'review' ? 'info' : 'accent'}
|
||||
onOpen={() => host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })}
|
||||
line={<>{phase(r)} · {store(r.store)}{r.version ? ` ${r.version}` : ''}</>}
|
||||
bar={r.phase === 'rollout' && r.fraction != null && r.fraction < 1 ? r.fraction : null}
|
||||
consoles={consoles(link(r.app), r.store, host, T)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!!s?.health.length && (
|
||||
<Section title={T('health')} tone="warn">
|
||||
{s.health.map(h => (
|
||||
<Row key={h.app} hue={h.hue} name={h.name} tone="warn" onOpen={() => host.action('route', route(h.app, 'health'))} openLabel={T('openApp', { name: h.name })}
|
||||
line={<><Pulse size={13} weight="bold" />{h.kinds.length > 1 ? T('crashAnr') : T(h.kinds[0]!)}</>} consoles={consoles(link(h.app), 'android', host, T)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{s && s.at && (
|
||||
<Section title={T('reviews')} aside={s.reviews.recent ? T('recent', { n: s.reviews.recent }) : T('recentNone')}>
|
||||
{s.reviews.low.map(r => (
|
||||
<button key={`${r.app}:${r.date}:${r.text}`} className="tc-review" onClick={() => host.action('route', route(r.app, 'reviews'))}>
|
||||
<span className="tc-stars" aria-label={T('stars', { n: r.rating })}>{[1, 2, 3, 4, 5].map(i => <Star key={i} size={11} weight={i <= r.rating ? 'fill' : 'regular'} />)}</span>
|
||||
<span className="tc-rname">{r.name}</span>
|
||||
<span className="tc-rtext">{r.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function consoles(l: CardApp | undefined, st: CardStore, host: CardHost, T: (k: CardKey) => string): ReactNode {
|
||||
const url = st === 'ios' ? l?.asc : l?.play;
|
||||
if (!url) return null;
|
||||
return <button className="tc-ext" onClick={e => { e.stopPropagation(); host.action('external', url); }} title={T(st === 'ios' ? 'asc' : 'play')} aria-label={T(st === 'ios' ? 'asc' : 'play')}><ArrowSquareOut size={14} /></button>;
|
||||
}
|
||||
|
||||
function Section({ title, tone, aside, children }: { title: string; tone?: 'bad' | 'warn'; aside?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={`tc-sec${tone ? ` tc-${tone}` : ''}`}>
|
||||
<h2><span>{title}</span>{aside && <em>{aside}</em>}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ hue, name, line, sub, tone, bar, onOpen, openLabel, consoles }: { hue: number; name: string; line: ReactNode; sub?: string | null; tone: string; bar?: number | null; onOpen: () => void; openLabel: string; consoles: ReactNode }) {
|
||||
return (
|
||||
<div className={`tc-row tone-${tone}`}>
|
||||
<button className="tc-open" onClick={onOpen} aria-label={openLabel}>
|
||||
<span className="tc-ic" style={{ '--tint-h': hue } as React.CSSProperties} aria-hidden>{initials(name)}</span>
|
||||
<span className="tc-txt">
|
||||
<span className="tc-name">{name}</span>
|
||||
<span className="tc-line">{line}</span>
|
||||
{sub && <span className="tc-sub">{sub}</span>}
|
||||
{bar != null && <span className="tc-bar" aria-hidden><i style={{ width: `${Math.max(2, Math.round(bar * 100))}%` }} /></span>}
|
||||
</span>
|
||||
</button>
|
||||
{consoles}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initials = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]!.toUpperCase()).join('') || '?';
|
||||
Loading…
Add table
Add a link
Reference in a new issue