Desktop: app page no longer overflows (page grid minmax, wrapping tabs); app ignore/unpair actions move to a ... menu; unique keys in App info

This commit is contained in:
CRTkafa 2026-10-23 09:08:23 +08:00
parent dece83c3e8
commit 5e82010ace
9 changed files with 32 additions and 21 deletions

View file

@ -108,7 +108,7 @@
.ds-app-sticky { position: sticky; top: -1px; z-index: 4; margin: 0 -28px; padding: 8px 28px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
/* ---------- Tabs v2 (pill) ---------- */
.ds-tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); overflow-x: auto; scrollbar-width: none; width: max-content; max-width: 100%; }
.ds-tabs { display: flex; gap: 4px; padding: 4px; border-radius: var(--ds-r); background: var(--ds-inset); border: 1px solid var(--ds-stroke); flex-wrap: wrap; width: max-content; max-width: 100%; box-sizing: border-box; }
.ds-tabs::-webkit-scrollbar { display: none; }
.ds-tab { all: unset; flex: none; display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600; color: var(--muted); cursor: pointer; white-space: nowrap; transition: background var(--speed) var(--ease), color var(--speed) var(--ease), box-shadow var(--speed) var(--ease); }
.ds-tab svg { width: 16px; height: 16px; }

View file

@ -2041,6 +2041,7 @@ export const en = {
'cm.refreshing': "Reading {name} again",
'cm.ignoredNoReads': "Ignored apps are not read",
'cm.ignore': "Ignore",
'cm.more': "More actions",
'cm.showIgnored': "Show ignored apps",
'cm.copyVersion': "Copy version",
'cm.copyBuild': "Copy build",

View file

@ -2043,6 +2043,7 @@ export const tr: Record<keyof typeof en, string> = {
'cm.refreshing': "{name} yeniden okunuyor",
'cm.ignoredNoReads': "Yok sayılan uygulamalar okunmaz",
'cm.ignore': "Yok say",
'cm.more': "Diğer işlemler",
'cm.showIgnored': "Yok sayılanları göster",
'cm.copyVersion': "Sürümü kopyala",
'cm.copyBuild': "Build'i kopyala",

View file

@ -1,13 +1,13 @@
// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no
// app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and
// "Start an app folder here" to make it a full app.
import { useContextMenu } from '../ui/ContextMenu';
import { useContextMenu, useMenuButton } from '../ui/ContextMenu';
import { appMenu } from '../ui/app-menu';
import { useShowIgnored } from '../lib/show-ignored';
import { useState } from 'react';
import { api, errorText } from '../api';
import { type Key, t } from '../i18n';
import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair } from '../lib/identity';
import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity';
import { attention, storeNow } from '../lib/model';
import { Hero, nextStep, Tabs } from '../ui/ds';
import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav';
@ -43,8 +43,8 @@ function AppControls({ app }: { app: UnifiedApp }) {
const d = useData();
const nav = useNav();
const cands = pairCandidates(app, d.apps);
const canUnpair = !app.project && !!app.ios && !!app.android;
const sides = (['ios', 'android'] as const).filter(k => app[k] || app.ignoredStores.includes(k));
const more = useMenuButton();
const [showIgnored] = useShowIgnored();
return (
<div className="app-controls">
<span className="small muted row" style={{ gap: 6 }}><Icon name="link" className="ic13" />{t(PAIRING[app.pairing])}</span>
@ -65,16 +65,8 @@ function AppControls({ app }: { app: UnifiedApp }) {
{cands.slice(0, 30).map(c => <option key={c.key} value={c.key}>{c.name} · {(c.ios ?? c.android)!.app.bundleId}</option>)}
</select></label>
)}
{canUnpair && <button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withUnpair(o, app.ios!.key, app.android!.key)).then(() => nav.go({ screen: 'portfolio' }))}><Icon name="unlink" />{t('app.unpair')}</button>}
{sides.length === 2 && sides.map(k => {
const off = app.ignored || app.ignoredStores.includes(k);
const store = k === 'ios' ? 'App Store' : 'Google Play';
return <button key={k} className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withSideIgnored(o, app, k, !off))}
title={off ? t('app.unignoreSideHint', { store }) : t('app.ignoreSideHint', { store })}><Icon name={off ? 'eye' : 'eyeOff'} />{off ? t('app.unignoreSide', { store }) : t('app.ignoreSide', { store })}</button>;
})}
{app.ignored
? <button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withIgnored(o, app.ignoreKeys, false))} title={t('app.unignoreHint')}><Icon name="eye" />{t('app.unignore')}</button>
: <button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withIgnored(o, app.ignoreKeys, true)).then(() => nav.go({ screen: 'portfolio' }))} title={t('app.ignoreHint')}><Icon name="eyeOff" />{t('app.ignore')}</button>}
<span className="grow" />
<button className="btn btn-sm btn-ghost" {...more(() => appMenu(app, d, nav, { showIgnored }))} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" />{app.ignored || app.ignoredStores.length ? t('nav.ignored') : null}</button>
</div>
);
}

View file

@ -107,8 +107,8 @@ function DataSafety({ project, d, onDone }: { project: Project; d: DeclarationsR
{ds.types.map(x => (
<tr key={x.id}>
<td><div className="strong">{typeLabel(x.id)}</div><div className="small muted">{x.category}{x.optional ? ` · ${t('ai.optional')}` : ''}{x.ephemeral ? ` · ${t('ai.ephemeral')}` : ''}</div></td>
<td><div className="chips">{x.collect.map(p => <Chip key={p} tone="neutral" dot={false}>{t(PURPOSE[p] ?? 'ds.functionality')}</Chip>)}</div></td>
<td>{x.shared ? <div className="chips">{x.share.map(p => <Chip key={p} tone="warn" dot={false}>{t(PURPOSE[p] ?? 'ds.functionality')}</Chip>)}</div> : <span className="muted small">{t('ai.notShared')}</span>}</td>
<td><div className="chips">{x.collect.map((p, i) => <Chip key={`${p}-${i}`} tone="neutral" dot={false}>{t(PURPOSE[p] ?? 'ds.functionality')}</Chip>)}</div></td>
<td>{x.shared ? <div className="chips">{x.share.map((p, i) => <Chip key={`${p}-${i}`} tone="warn" dot={false}>{t(PURPOSE[p] ?? 'ds.functionality')}</Chip>)}</div> : <span className="muted small">{t('ai.notShared')}</span>}</td>
</tr>
))}
</tbody>
@ -138,7 +138,7 @@ function AppPrivacy({ project, d, appId }: { project: Project; d: DeclarationsRe
{ap.items.map(x => (
<tr key={x.apple}>
<td><div className="strong">{x.apple}</div><div className="small muted">{x.category} · {x.from.map(typeLabel).join(', ')}</div></td>
<td><div className="chips">{x.purposes.map(p => <Chip key={p} tone={/Third-Party/.test(p) ? 'warn' : 'neutral'} dot={false}>{p}</Chip>)}</div></td>
<td><div className="chips">{x.purposes.map((p, i) => <Chip key={`${p}-${i}`} tone={/Third-Party/.test(p) ? 'warn' : 'neutral'} dot={false}>{p}</Chip>)}</div></td>
<td><Chip tone="warn" icon="question">{t('ai.ask')}</Chip></td>
<td><Chip tone={x.thirdParty ? 'warn' : 'outline'} icon="question">{x.thirdParty ? t('ai.askLikely') : t('ai.ask')}</Chip></td>
</tr>

View file

@ -176,7 +176,7 @@ svg { flex: none; }
.reader-line > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dev-pill { font: 700 10px/1 var(--mono); letter-spacing: .06em; color: var(--warn-ink); background: var(--warn-bg); border: 1px solid color-mix(in srgb, var(--warn-solid) 45%, transparent); padding: 5px 7px; border-radius: 5px; }
.content { flex: 1; overflow: auto; min-height: 0; scroll-behavior: smooth; }
.page { padding: 24px 28px 48px; max-width: 1360px; margin: 0 auto; display: grid; gap: 20px; }
.page { padding: 24px 28px 48px; max-width: 1360px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
@media (prefers-reduced-motion: reduce) { .content { scroll-behavior: auto; } }
/* ---------- page header ---------- */

View file

@ -39,6 +39,19 @@ export function useContextMenu(): (build: () => MenuInput) => { onContextMenu: (
}), [open]);
}
/** Props for a "..." overflow button that opens the same menu under itself (click, Enter or Space). */
export function useMenuButton(): (build: () => MenuInput) => { onClick: (e: ReactMouseEvent) => void; 'aria-haspopup': 'menu' } {
const open = useContext(Ctx);
return useCallback((build: () => MenuInput) => ({
'aria-haspopup': 'menu' as const,
onClick: (e: ReactMouseEvent) => {
if (!open) return;
const r = (e.currentTarget as Element).getBoundingClientRect();
open(Math.round(r.left), Math.round(r.bottom + 4), build(), { from: e.currentTarget as Element, keyboard: e.detail === 0 });
},
}), [open]);
}
/** Copies and says so in a toast-less way (the menu closes; failures are silent like the browser's own Copy). */
export const copyEntry = (label: string, text: string | null | undefined, icon: MenuItem['icon'] = 'copy'): MenuItem | null =>
text ? { label, icon, onSelect: () => { void copyText(text); } } : null;

View file

@ -3,7 +3,7 @@
import { api, errorText } from '../api';
import { t } from '../i18n';
import { copyText } from '../lib/crash';
import { withIgnored, withSideIgnored, type UnifiedApp } from '../lib/identity';
import { withIgnored, withSideIgnored, withUnpair, type UnifiedApp } from '../lib/identity';
import { setShowIgnored } from '../lib/show-ignored';
import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav';
import type { Data } from '../store';
@ -57,6 +57,9 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored:
onSelect: () => { void d.updateOverrides(o => withSideIgnored(o, app, k, !off)); } };
}) : []),
] },
!p && !!app.ios && !!app.android && { label: t('app.unpair'), icon: 'unlink', onSelect: () => {
void d.updateOverrides(o => withUnpair(o, app.ios!.key, app.android!.key)).then(() => nav.go({ screen: 'portfolio' }));
} },
{ label: t('cm.showIgnored'), icon: 'filter', checked: opts.showIgnored, onSelect: () => setShowIgnored(!opts.showIgnored) },
];
}

View file

@ -26,6 +26,7 @@ import { DeviceMobileIcon } from '@phosphor-icons/react/dist/csr/DeviceMobile';
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 { 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';
@ -84,7 +85,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,
eye: EyeIcon, eyeOff: EyeSlashIcon, link: LinkIcon, unlink: LinkBreakIcon, bell: BellIcon, filter: FunnelIcon, play: PlayIcon,
more: DotsThreeIcon, 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,