Desktop: compact app hero on every tab but Releases; Ctrl+K lists the current app's menu actions; Alt+Up/Down switches apps; shortcuts list; no focus ring on code-focused headings
This commit is contained in:
parent
a656240bfb
commit
7c18b3f7dd
7 changed files with 63 additions and 10 deletions
|
|
@ -26,6 +26,7 @@ import { ErrorBoundary } from './ui/ErrorBoundary';
|
|||
import { Toasts } from './ui/Toasts';
|
||||
import { useContextMenu } from './ui/ContextMenu';
|
||||
import { appMenu } from './ui/app-menu';
|
||||
import { isEditable, isItem, type MenuInput } from './ui/menu-logic';
|
||||
import { worstTone } from './ui/ds';
|
||||
import { attention } from './lib/model';
|
||||
|
||||
|
|
@ -53,6 +54,15 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
const listed = showIgnored ? [...visible, ...ignoredApps] : visible;
|
||||
const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }];
|
||||
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it)
|
||||
const sideOrder = groups.flatMap(x => x.rows.map(r => r.app));
|
||||
const stepApp = useCallback((dir: 1 | -1) => {
|
||||
if (!sideOrder.length) return;
|
||||
const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1;
|
||||
const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!;
|
||||
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' });
|
||||
}, [sideOrder, route, go]);
|
||||
const toggleGroup = (id: string) => setCollapsed(c => {
|
||||
const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id];
|
||||
try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ }
|
||||
|
|
@ -89,6 +99,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; }
|
||||
if (palette || welcome) return;
|
||||
if (e.altKey && !mod && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !isEditable(e.target as HTMLInputElement)) { e.preventDefault(); stepApp(e.key === 'ArrowDown' ? 1 : -1); return; }
|
||||
if (mod && !e.shiftKey && !e.altKey && /^[1-6]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
const n = Number(e.key);
|
||||
|
|
@ -103,7 +114,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [palette, welcome, route, go, tabs]);
|
||||
}, [palette, welcome, route, go, tabs, stepApp]);
|
||||
|
||||
const closeWelcome = useCallback(() => {
|
||||
setWelcome(false);
|
||||
|
|
@ -118,10 +129,24 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name;
|
||||
for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) },
|
||||
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) });
|
||||
c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) });
|
||||
c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) });
|
||||
c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) });
|
||||
WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) }));
|
||||
for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) });
|
||||
if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
|
||||
// the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is
|
||||
// also one Ctrl+K away; opening a tab is already above
|
||||
if (app) {
|
||||
const skip = new Set([t('cm.open'), t('cm.openTab')]);
|
||||
const add = (items: MenuInput, prefix: string) => items.forEach((e, i) => {
|
||||
if (!e || !isItem(e) || skip.has(e.label) || e.disabled) return;
|
||||
const title = prefix ? `${prefix} · ${e.label}` : e.label;
|
||||
if (e.submenu) { add(e.submenu, title); return; }
|
||||
if (e.onSelect) c.push({ id: `appcmd-${prefix}-${i}-${e.label}`, group: t('palette.gApp', { name: app.name }), title, icon: e.icon, hint: e.shortcut, keywords: app.name, run: e.onSelect });
|
||||
});
|
||||
add(appMenu(app, d, { route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }, { showIgnored }), '');
|
||||
}
|
||||
for (const x of exports) c.push({ id: `export-${x.id}`, group: G.exp, title: t('palette.export', { what: x.label }), icon: x.icon ?? 'download', keywords: `export save download ${x.kind}`, hint: x.disabled || `.${x.kind}`, run: () => { runExport(x); } });
|
||||
if (app?.project) {
|
||||
const p = app.project.id;
|
||||
|
|
@ -137,7 +162,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme });
|
||||
c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) });
|
||||
return c;
|
||||
}, [d, visible, app, tabs, route, go, onTheme, exports, runExport]);
|
||||
}, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp]);
|
||||
|
||||
const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }]
|
||||
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }]
|
||||
|
|
@ -148,7 +173,6 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
|
||||
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
||||
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
|
||||
const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }];
|
||||
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
|
||||
|
||||
const menu = useContextMenu();
|
||||
|
|
|
|||
|
|
@ -99,6 +99,16 @@
|
|||
.ds-hero-shots img { height: 280px; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 16px; box-shadow: 0 18px 36px -14px rgba(0, 0, 0, .55); flex: none; transition: transform var(--speed) var(--ease); }
|
||||
.ds-hero-shots img:nth-child(even) { margin-top: 26px; }
|
||||
.ds-hero:hover .ds-hero-shots img { transform: translateY(-3px); }
|
||||
/* compact hero: every app tab but Releases (one slim row, the tab content starts above the fold) */
|
||||
.ds-hero.compact { grid-template-columns: minmax(0, 1fr); }
|
||||
.ds-hero.compact .ds-hero-main { padding: 14px 20px; gap: 0; }
|
||||
.ds-hero.compact .ds-hero-icon { width: 52px; height: 52px; border-radius: 14px; font-size: 18px; }
|
||||
.ds-hero.compact .ds-hero-id { gap: 14px; }
|
||||
.ds-hero.compact .ds-hero-t { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
|
||||
.ds-hero.compact .ds-hero-row { flex-basis: 100%; }
|
||||
.ds-hero.compact .ds-hero-name { font-size: 20px; }
|
||||
.ds-hero.compact .ds-hero-ids .store-badge { height: 26px; }
|
||||
.ds-hero-cta { margin-left: auto; flex: none; }
|
||||
@media (max-width: 1180px) { .ds-hero { grid-template-columns: minmax(0, 1fr); } .ds-hero-shots { display: none; } }
|
||||
@media (max-width: 900px) { .ds-hero-pipe { grid-template-columns: minmax(0, 1fr); } .ds-hero-foot { justify-items: start; } }
|
||||
|
||||
|
|
@ -220,3 +230,5 @@
|
|||
.ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); }
|
||||
@media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } }
|
||||
:root[data-motion="reduce"] .ds-menu { transition: none; transform: none; }
|
||||
/* headings focused by code (dialog / wizard step titles) for screen readers: no ring */
|
||||
[tabindex="-1"]:focus:not(:focus-visible), h1[tabindex="-1"]:focus, h2[tabindex="-1"]:focus { outline: none; }
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ export const en = {
|
|||
'palette.gApps': 'Apps',
|
||||
'palette.gGo': 'Go to',
|
||||
'palette.gTabs': 'App tabs',
|
||||
'palette.gApp': 'This app: {name}',
|
||||
'palette.nextApp': 'Next app',
|
||||
'palette.prevApp': 'Previous app',
|
||||
'settings.scApps': 'Previous / next app',
|
||||
'settings.scMenu': 'Context menu of the focused item',
|
||||
'palette.gActions': 'Actions',
|
||||
'palette.openApp': 'Open',
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'palette.gApps': 'Uygulamalar',
|
||||
'palette.gGo': 'Git',
|
||||
'palette.gTabs': 'Uygulama sekmeleri',
|
||||
'palette.gApp': 'Bu uygulama: {name}',
|
||||
'palette.nextApp': 'Sonraki uygulama',
|
||||
'palette.prevApp': 'Önceki uygulama',
|
||||
'settings.scApps': 'Önceki / sonraki uygulama',
|
||||
'settings.scMenu': 'Odaktaki öğenin bağlam menüsü',
|
||||
'palette.gActions': 'İşlemler',
|
||||
'palette.openApp': 'Aç',
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
|
|||
{android && <button className="btn btn-sm btn-ghost" onClick={() => open(playUrlFor(d, app))} title={t('app.consoleHint')}><Icon name="external" />Play Console</button>}
|
||||
</>}
|
||||
all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null}
|
||||
compact={tab !== 'releases'}
|
||||
cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} />
|
||||
<AppControls app={app} />
|
||||
{!app.project && <StoreOnlyBanner app={app} />}
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
<dt>{t('nav.portfolio')}</dt><dd><Kbd keys={[MOD, '1']} /></dd>
|
||||
<dt>{t('settings.scWorkspace')}</dt><dd><Kbd keys={[MOD, '2']} /><span className="muted">–</span><Kbd keys={[MOD, '6']} /></dd>
|
||||
<dt>{t('settings.scTabs')}</dt><dd><Kbd keys={[MOD, '⇧', '[']} /><Kbd keys={[MOD, '⇧', ']']} /></dd>
|
||||
<dt>{t('settings.scApps')}</dt><dd><Kbd keys={['Alt', '↑']} /><Kbd keys={['Alt', '↓']} /></dd>
|
||||
<dt>{t('settings.scMenu')}</dt><dd><Kbd keys={['⇧', 'F10']} /></dd>
|
||||
<dt>{t('settings.scTabArrows')}</dt><dd><Kbd keys={['←', '→']} /></dd>
|
||||
<dt>{t('settings.scClose')}</dt><dd><Kbd keys={['Esc']} /></dd>
|
||||
<dt>{t('set.scQuit')}</dt><dd><Kbd keys={[MOD, 'Q']} /></dd>
|
||||
|
|
|
|||
|
|
@ -179,16 +179,19 @@ export function PageHeader({ title, subtitle, eyebrow, actions, icon }: { title:
|
|||
}
|
||||
|
||||
/** App hero: screenshots strip, big icon, name, ids, store links, the two-store pipeline and the next step. */
|
||||
export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children, onContextMenu }: {
|
||||
export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children, onContextMenu, compact }: {
|
||||
name: string; icon: string | null; projectId: string | null; ids?: ReactNode; badges?: ReactNode; links?: ReactNode;
|
||||
/** The app's context menu (useContextMenu props). */
|
||||
onContextMenu?: (e: ReactMouseEvent) => void;
|
||||
/** One slim row (icon, name, ids, next step): every tab but the overview, so the tab's content starts high. */
|
||||
compact?: boolean;
|
||||
all: StoreRelease[]; ios: boolean; android: boolean; cta?: Cta | null; onCta?: (tab: AppTab) => void; next?: string | null; children?: ReactNode;
|
||||
}) {
|
||||
const hue = useTint(icon, name);
|
||||
const shots = useShots(projectId, 4);
|
||||
const shots = useShots(compact ? null : projectId, 4);
|
||||
const ctaBtn = cta && <button className={`btn ${cta.primary ? 'btn-primary' : ''} pf-cta`} onClick={() => onCta?.(cta.tab)}><Icon name={cta.icon} />{cta.text}</button>;
|
||||
return (
|
||||
<section className="ds-hero" style={tintStyle(hue)} aria-label={name} onContextMenu={onContextMenu}>
|
||||
<section className={`ds-hero${compact ? ' compact' : ''}`} style={tintStyle(hue)} aria-label={name} onContextMenu={onContextMenu}>
|
||||
<div className="ds-hero-main">
|
||||
<div className="ds-hero-id">
|
||||
<AppIcon name={name} src={icon} className="ds-hero-icon" />
|
||||
|
|
@ -197,18 +200,19 @@ export function Hero({ name, icon, projectId, ids, badges, links, all, ios, andr
|
|||
{ids && <div className="ds-hero-ids">{ids}</div>}
|
||||
{links && <div className="ds-hero-links">{links}</div>}
|
||||
</div>
|
||||
{compact && ctaBtn && <div className="ds-hero-cta">{ctaBtn}</div>}
|
||||
</div>
|
||||
<div className="ds-hero-pipe">
|
||||
{!compact && <div className="ds-hero-pipe">
|
||||
<Pipeline all={all} ios={ios} android={android} size="lg" />
|
||||
<div className="ds-hero-foot">
|
||||
<div className="ds-hero-next"><span className="pf-next-l">{t('pf.next')}</span><span className="ds-hero-next-v num">{next ?? '–'}</span></div>
|
||||
{cta && <button className={`btn ${cta.primary ? 'btn-primary' : ''} pf-cta`} onClick={() => onCta?.(cta.tab)}><Icon name={cta.icon} />{cta.text}</button>}
|
||||
{ctaBtn}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
{children}
|
||||
</div>
|
||||
{/* no screenshots: no second (cropped) copy of the icon, which already leads the hero */}
|
||||
{shots.length > 0 && <div className="ds-hero-shots" aria-hidden="true">
|
||||
{!compact && shots.length > 0 && <div className="ds-hero-shots" aria-hidden="true">
|
||||
{shots.map((s, i) => <img key={i} src={s} alt="" draggable={false} />)}
|
||||
</div>}
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue