Desktop: per-store ignore (hide only the App Store or Google Play side), N ignored show toggle in sidebar and portfolio, Unignore in the app controls; ignore flags sync with the shared app list

This commit is contained in:
CRTkafa 2026-10-23 01:35:27 +08:00
parent 13b91dd423
commit 9610c9477e
14 changed files with 347 additions and 17 deletions

View file

@ -13,6 +13,7 @@ import { AppFolders } from './screens/AppFolders';
import { AppView } from './screens/AppView';
import { Dependencies } from './screens/Dependencies';
import { Ignored } from './screens/Ignored';
import { useShowIgnored } from './lib/show-ignored';
import { Onboarding } from './screens/Onboarding';
import { Portfolio } from './screens/Portfolio';
import { Settings } from './screens/Settings';
@ -47,6 +48,9 @@ export function App({ onTheme }: { onTheme: () => void }) {
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
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 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 */ }
@ -142,7 +146,7 @@ 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(visible.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: visible.map(a => ({ app: a })) }];
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 appItem = (a: UnifiedApp) => {
@ -152,10 +156,12 @@ export function App({ onTheme }: { onTheme: () => void }) {
const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0);
return (
<div role="listitem" key={a.key}>
<button className="nav nav-app" aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
<button className={`nav nav-app${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' })}>
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{tone && <span className={`ds-sdot ${tone}`} role="img" aria-label={tone === 'bad' ? t('pf.blocker') : tone === 'warn' ? t('pf.warning') : t('pf.note')} />}</span>
<span className="grow">{a.name}</span>
{a.ignored && <span className="so-dot" title={t('ignored.rowHint')}><Icon name="eyeOff" /></span>}
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
{missing ? <span className="tag-count bad" title={t('folder.missingShort')} aria-label={t('folder.missingShort')}>!</span>
: bad > 0 && <span className="tag-count bad" aria-label={t('nav.problems', { count: bad })}>{bad}</span>}
@ -196,7 +202,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
</div>
);
})}
{!visible.length && <p className="side-empty">{t('nav.noApps')}</p>}
{!visible.length && !showIgnored && <p className="side-empty">{t('nav.noApps')}</p>}
{ignoredApps.length > 0 && <button className="link side-ignored-toggle" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)} data-testid="toggle-ignored">{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>}
<div className="side-label"><span>{t('nav.workspace')}</span></div>
{WORKSPACE.map((s, i) => (
<button key={s} className="nav" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${t(WS_KEY[s])} (${MOD} ${i + 2})`}>

View file

@ -737,6 +737,16 @@ export const en = {
'app.unpair': 'Unpair',
'app.ignore': 'Ignore',
'app.ignoreHint': 'Leave this app out of the portfolio and store reads (Ignored lists it to restore)',
'app.unignore': 'Stop ignoring',
'app.unignoreHint': 'Bring this app back into the portfolio and store reads',
'app.ignoreSide': 'Ignore {store}',
'app.unignoreSide': 'Stop ignoring {store}',
'app.ignoreSideHint': 'Leave only the {store} side of this app out of reads and notifications. Storebound only: the store itself is untouched',
'app.unignoreSideHint': 'Read the {store} side of this app again',
'ignored.show': '{count} ignored · show',
'ignored.hide': '{count} ignored · hide',
'ignored.rowHint': 'Ignored: not read, not watched',
'ignored.sideHint': 'Ignored on {stores}',
'app.studioHint': 'Open the studio',
'app.storeOnlyTitle': 'Store only.',
'app.storeOnlyText': 'This app is on your store accounts but has no app folder: releases, reviews, health and TestFlight are shown read-only. Link its folder, or start one, to manage listings, products and checks.',

View file

@ -738,6 +738,16 @@ export const tr: Record<keyof typeof en, string> = {
'app.pick': 'Bir uygulama seçin',
'app.unpair': 'Eşleşmeyi ayır',
'app.ignore': 'Yok say',
'app.unignore': 'Yok saymayı bırak',
'app.unignoreHint': 'Bu uygulamayı portföye ve mağaza okumalarına geri al',
'app.ignoreSide': '{store} tarafını yok say',
'app.unignoreSide': '{store} tarafını geri al',
'app.ignoreSideHint': 'Uygulamanın yalnızca {store} tarafını okumalardan ve bildirimlerden çıkarır. Yalnızca storebound: mağazaya dokunulmaz',
'app.unignoreSideHint': 'Uygulamanın {store} tarafını yeniden oku',
'ignored.show': '{count} yok sayılan · göster',
'ignored.hide': '{count} yok sayılan · gizle',
'ignored.rowHint': 'Yok sayılıyor: okunmaz, izlenmez',
'ignored.sideHint': '{stores} tarafı yok sayılıyor',
'app.ignoreHint': 'Bu uygulamayı portföy ve mağaza okumalarının dışında bırak (Yok sayılanlar ekranından geri getirilir)',
'app.studioHint': 'Stüdyoyu aç',
'app.storeOnlyTitle': 'Yalnızca mağaza.',

View file

@ -5,7 +5,7 @@ import type { UnifiedApp as CoreApp } from '../../../../cli/lib/identity';
export {
buildApps, type BuildInput, NO_OVERRIDES, normId, normName, type Overrides, type Pair, pairCandidates, type Pairing, refKey, type StoreRef, storeTarget,
withIgnored, withLink, withPair, withStudio, withUnpair,
withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair,
} from '../../../../cli/lib/identity';
export type UnifiedApp = CoreApp<Project>;

View file

@ -0,0 +1,25 @@
// "N ignored · show": whether the sidebar and the portfolio list the ignored apps (dimmed, with Restore). A per-viewer
// convenience kept in localStorage (a private window or blocked storage just means hidden), shared by every list.
import { useEffect, useState } from 'react';
const KEY = 'storebound.showIgnored';
const EVT = 'storebound:show-ignored';
export function readShowIgnored(): boolean {
try { return localStorage.getItem(KEY) === '1'; } catch { return false; }
}
export function setShowIgnored(on: boolean): void {
try { localStorage.setItem(KEY, on ? '1' : '0'); } catch { /* private mode */ }
window.dispatchEvent(new Event(EVT));
}
export function useShowIgnored(): [boolean, (on: boolean) => void] {
const [on, set] = useState(readShowIgnored);
useEffect(() => {
const f = () => set(readShowIgnored());
window.addEventListener(EVT, f);
return () => window.removeEventListener(EVT, f);
}, []);
return [on, setShowIgnored];
}

View file

@ -4,7 +4,7 @@
import { useState } from 'react';
import { api, errorText } from '../api';
import { type Key, t } from '../i18n';
import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withStudio, withUnpair } from '../lib/identity';
import { pairCandidates, type UnifiedApp, withIgnored, withLink, withPair, withSideIgnored, withStudio, withUnpair } 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';
@ -41,6 +41,7 @@ function AppControls({ app }: { app: UnifiedApp }) {
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));
return (
<div className="app-controls">
<span className="small muted row" style={{ gap: 6 }}><Icon name="link" className="ic13" />{t(PAIRING[app.pairing])}</span>
@ -62,7 +63,15 @@ function AppControls({ app }: { app: UnifiedApp }) {
</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>}
<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>
{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>}
</div>
);
}

View file

@ -1,7 +1,7 @@
// Ignored: apps and whole store accounts left out of the portfolio, the sidebar and store reads. Restore brings them
// back (nothing on the stores changes either way).
import { t } from '../i18n';
import { withIgnored } from '../lib/identity';
import { withIgnored, withSideIgnored } from '../lib/identity';
import { useNav } from '../nav';
import { useData } from '../store';
import { Icon, StoreGlyph } from '../ui/icons';
@ -12,7 +12,7 @@ import '../styles/folders.css';
export function Ignored() {
const d = useData();
const nav = useNav();
const apps = d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name));
const apps = d.apps.filter(a => a.ignored || a.ignoredStores.length > 0).sort((a, b) => a.name.localeCompare(b.name));
const accounts = d.accounts.filter(a => d.overrides.ignored.includes(`account:${a.id}`));
const restoreAll = () => d.updateOverrides(o => ({ ...o, ignored: [] }));
const empty = !apps.length && !accounts.length;
@ -37,7 +37,8 @@ export function Ignored() {
{a.android && <span className="row" style={{ gap: 4 }}><StoreGlyph store="android" className="ic13" /><span className="mono-id">{a.android.app.id}</span></span>}
</span>
</div>
<button className="btn btn-sm" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))}><Icon name="eye" />{t('ignored.restore')}</button>
{a.ignoredStores.map(k => <button key={k} className="btn btn-sm" onClick={() => d.updateOverrides(o => withSideIgnored(o, a, k, false))}><Icon name="eye" />{t('app.unignoreSide', { store: k === 'ios' ? 'App Store' : 'Google Play' })}</button>)}
{a.ignored && <button className="btn btn-sm" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))}><Icon name="eye" />{t('ignored.restore')}</button>}
</div>
);
})}

View file

@ -8,7 +8,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
import { type Key, t } from '../i18n';
import { ago, pct, share } from '../lib/format';
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity';
import { useShowIgnored } from '../lib/show-ignored';
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp, withIgnored } from '../lib/identity';
import { type Attention, attention, healthSeries, type HealthSnapshot, isError, laneOf, PLAY_THRESHOLDS, STAGES, storeNow, type Tone } from '../lib/model';
import { type AppTab, useNav } from '../nav';
import type { IconName } from '../ui/icons';
@ -282,6 +283,8 @@ export function Portfolio() {
const [query, setQuery] = useState('');
const setView = (v: Partial<View>) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; });
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored), [d.apps]);
const [showIgnored, setShowIgnored] = useShowIgnored();
const [round, setRound] = useState(0);
const [rechecking, setRechecking] = useState(false);
const health = useCachedHealth(visible.map(a => a.target), round);
@ -427,6 +430,18 @@ export function Portfolio() {
<Seg value={view.layout} onChange={layout => setView({ layout })} label={t('pf.layout')} options={[['cards', t('pf.cards'), 'grid'], ['table', t('pf.table'), 'layers']]} />
</div>
{ignoredApps.length > 0 && (
<div className="row small" style={{ gap: 8, flexWrap: 'wrap' }} data-testid="ignored-strip">
<button className="link" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)}>{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>
{showIgnored && ignoredApps.map(a => (
<span key={a.key} className="row muted" style={{ gap: 4 }}>
<button className="link" onClick={() => open(a)}>{a.name}</button>
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, a.ignoreKeys, false))} aria-label={t('ignored.restore')} title={t('ignored.restore')}><Icon name="eye" /></button>
</span>
))}
</div>
)}
{view.layout === 'cards' && (rows.length ? grouped.map(g => {
const s = g.studio === undefined ? undefined : d.studios.find(x => x.id === g.studio) ?? null;
return (

View file

@ -59,7 +59,8 @@ export function classifyRead(result: unknown, error: unknown, task: ReadTask<unk
export function platformsOf(a: UnifiedApp | undefined, status?: AppStatus): { ios: boolean; android: boolean } {
if (!a) return { ios: false, android: false };
const pl = status?.app?.platforms as { ios?: unknown; android?: unknown } | undefined;
return { ios: !!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl), android: !!a.android || !!pl?.android || (!!a.project?.playAccount && !pl) };
// a store side the owner ignored is not read (reviews, health, status of that store)
return { ios: !a.ignoredStores.includes('ios') && (!!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl)), android: !a.ignoredStores.includes('android') && (!!a.android || !!pl?.android || (!!a.project?.playAccount && !pl)) };
}
export interface Data {