274 lines
20 KiB
TypeScript
274 lines
20 KiB
TypeScript
// Settings, every part of the app in one place: studios and accounts, app pairing and the ignore list, store reads
|
|
// (on launch, every N minutes, how many apps at once, icons), the offline cache, language and theme, notifications,
|
|
// local tools, the CLI sidecar, privacy, keyboard shortcuts, updates and reset. A section list on the left jumps.
|
|
import { useEffect, useState } from 'react';
|
|
import { useScrollSpy } from '../ui/useScrollSpy';
|
|
import { api, type CacheInfo, errorText, onUpdateProgress, type Settings as S, type UpdateInfo } from '../api';
|
|
import { type Key, t } from '../i18n';
|
|
import { ago, bytes, date, num } from '../lib/format';
|
|
import { pairCandidates, type UnifiedApp, withPair, withUnpair } from '../lib/identity';
|
|
import { useNav } from '../nav';
|
|
import { usePrefs } from '../prefs-ctx';
|
|
import { useData } from '../store';
|
|
import { Icon, type IconName, StoreGlyph } from '../ui/icons';
|
|
import { AppIcon, Card, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit';
|
|
|
|
export const SECTIONS: [string, IconName, Key][] = [
|
|
['studios', 'building', 'set.studios'], ['pairing', 'link', 'set.pairing'], ['refresh', 'refresh', 'set.refresh'], ['cache', 'database', 'set.cache'],
|
|
['appearance', 'sun', 'settings.appearance'], ['notifications', 'bell', 'set.notifications'], ['dependencies', 'package', 'nav.dependencies'], ['cli', 'terminal', 'set.cli'],
|
|
['privacy', 'shield', 'set.privacy'], ['shortcuts', 'keyboard', 'settings.shortcuts'], ['updates', 'download', 'set.updates'], ['reset', 'rotate', 'set.reset'],
|
|
];
|
|
const SECTION_IDS = SECTIONS.map(([id]) => id);
|
|
|
|
function Toggle({ id, label, hint, checked, onChange }: { id: string; label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<label className="toggle-row" htmlFor={id}>
|
|
<span className="grow"><span className="strong">{label}</span>{hint && <span className="small muted">{hint}</span>}</span>
|
|
<input id={id} type="checkbox" role="switch" className="switch" checked={checked} onChange={e => onChange(e.target.checked)} />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function PairRow({ app, all }: { app: UnifiedApp; all: UnifiedApp[] }) {
|
|
const d = useData();
|
|
const cands = pairCandidates(app, all);
|
|
const r = (app.ios ?? app.android)!;
|
|
return (
|
|
<tr>
|
|
<td><div className="cell-app"><AppIcon name={app.name} src={d.iconFor(app)} /><div className="t"><span className="n">{app.name}</span><div className="s mono-id">{r.app.bundleId}</div></div></div></td>
|
|
<td>{app.ios && app.android ? <span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" /><StoreGlyph store="android" className="ic13" />{t(`pair.${app.pairing}` as Key)}</span>
|
|
: <span className="row" style={{ gap: 6 }}><StoreGlyph store={app.ios ? 'ios' : 'android'} className="ic13" />{t('pair.single')}</span>}</td>
|
|
<td className="right">
|
|
{app.ios && app.android ? <button className="btn btn-sm btn-ghost" onClick={() => d.updateOverrides(o => withUnpair(o, app.ios!.key, app.android!.key))}><Icon name="unlink" />{t('app.unpair')}</button>
|
|
: cands.length ? (
|
|
<select className="input input-sm" value="" aria-label={t('app.pairWithName', { name: app.name })} onChange={e => {
|
|
const other = cands.find(c => c.key === e.target.value);
|
|
if (other) d.updateOverrides(o => withPair(o, (app.ios ?? other.ios)!.key, (app.android ?? other.android)!.key));
|
|
}}>
|
|
<option value="">{t('app.pairWith')}</option>
|
|
{cands.slice(0, 30).map(c => <option key={c.key} value={c.key}>{c.name} · {(c.ios ?? c.android)!.app.bundleId}</option>)}
|
|
</select>
|
|
) : <span className="small muted">{t('set.noCandidates')}</span>}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void; section?: string }) {
|
|
const d = useData();
|
|
const nav = useNav();
|
|
const { prefs, update } = usePrefs();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [cache, setCache] = useState<CacheInfo | null>(null);
|
|
const { active, jump } = useScrollSpy(SECTION_IDS, 'set-', section ?? 'studios', prefs.motion !== 'reduce');
|
|
const s = d.settings;
|
|
const tools = d.sidecar?.tools;
|
|
const set = (patch: (x: S) => S) => d.updateSettings(patch(s));
|
|
const loadCache = () => api.cacheInfo().then(setCache, () => {});
|
|
useEffect(() => { loadCache(); }, []);
|
|
useEffect(() => { if (section) jump(section, { instant: true }); }, [section, jump]);
|
|
const clear = () => api.clearCache().then(() => { d.toast({ kind: 'ok', title: t('settings.cacheCleared') }); loadCache(); }, e => setError(errorText(e)));
|
|
const reset = async () => {
|
|
try {
|
|
if (await api.reset()) {
|
|
try { localStorage.clear(); } catch { /* private mode */ }
|
|
await d.reload();
|
|
d.toast({ kind: 'ok', title: t('set.resetDone') });
|
|
nav.openWelcome();
|
|
}
|
|
} catch (e) { setError(errorText(e)); }
|
|
};
|
|
const storeOnly = d.apps.filter(a => !a.project && !a.ignored);
|
|
const paired = storeOnly.filter(a => a.ios && a.android);
|
|
const singles = storeOnly.filter(a => !(a.ios && a.android));
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="ph"><div><h1>{t('nav.settings')}</h1><p>{t('set.lead')}</p></div></div>
|
|
{error && <Notice tone="bad">{error}</Notice>}
|
|
<div className="set-layout">
|
|
<nav className="set-nav" aria-label={t('set.sections')}>
|
|
{SECTIONS.map(([id, icon, key]) => (
|
|
<button key={id} className="nav" aria-current={active === id ? 'true' : undefined} onClick={() => jump(id)}><Icon name={icon} /><span className="grow">{t(key)}</span></button>
|
|
))}
|
|
</nav>
|
|
<div className="stack set-body" style={{ gap: 16 }}>
|
|
<Card id="set-studios" title={t('set.studios')} icon="building" sub={t('set.studiosSub')} className="set-card" actions={<button className="btn btn-sm" onClick={() => nav.go({ screen: 'accounts' })}>{t('set.manage')}<Icon name="arrowRight" /></button>}>
|
|
{d.studios.length ? (
|
|
<div className="studio-sum">
|
|
{d.studios.map(st => {
|
|
const accts = d.accounts.filter(a => a.studio === st.id);
|
|
return (
|
|
<button key={st.id} className="studio-sum-item" onClick={() => nav.go({ screen: 'accounts', section: st.id })}>
|
|
<StudioMark studio={st} size="md" />
|
|
<span className="stack" style={{ gap: 2 }}><b>{st.name}</b><span className="small muted">{t('set.studioLine', { play: accts.filter(a => a.kind === 'play').length, apple: accts.filter(a => a.kind === 'apple').length, apps: d.apps.filter(a => a.studio === st.id && !a.ignored).length })}</span></span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : <p className="small muted">{t('set.noStudios')}</p>}
|
|
</Card>
|
|
|
|
<Card id="set-pairing" title={t('set.pairing')} icon="link" sub={t('set.pairingSub')} pad={false} className="set-card">
|
|
{storeOnly.length ? (
|
|
<table className="table">
|
|
<thead><tr><th>{t('col.app')}</th><th>{t('set.pairedHow')}</th><th className="right"><span className="sr">{t('col.actions')}</span></th></tr></thead>
|
|
<tbody>{[...paired, ...singles].map(a => <PairRow key={a.key} app={a} all={d.apps} />)}</tbody>
|
|
</table>
|
|
) : <div className="card-body"><p className="small muted">{t('set.noStoreOnly')}</p></div>}
|
|
<div className="card-foot"><Icon name="eyeOff" />{t('set.ignoredLine', { count: d.apps.filter(a => a.ignored).length, accounts: d.overrides.ignored.filter(i => i.startsWith('account:')).length })}
|
|
<button className="link" onClick={() => nav.go({ screen: 'ignored' })}>{t('set.openIgnored')}</button></div>
|
|
</Card>
|
|
|
|
<Card id="set-refresh" title={t('set.refresh')} icon="refresh" sub={t('set.refreshSub')} className="set-card">
|
|
<div className="form">
|
|
<div className="field"><span className="legend">{t('set.autoRefresh')}</span>
|
|
<Seg value={String(s.refresh.autoMinutes) as '0'} label={t('set.autoRefresh')} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, autoMinutes: Number(v) as S['refresh']['autoMinutes'] } }))}
|
|
options={[['0', t('set.off')], ['15', t('set.min', { n: 15 })], ['30', t('set.min', { n: 30 })], ['60', t('set.hour', { n: 1 })], ['180', t('set.hour', { n: 3 })], ['720', t('set.hour', { n: 12 })]]} />
|
|
<span className="hint">{t('set.autoHint')}</span></div>
|
|
<Toggle id="on-launch" label={t('set.onLaunch')} hint={t('set.onLaunchHint')} checked={s.refresh.onLaunch} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, onLaunch: v } }))} />
|
|
<Toggle id="icons" label={t('set.icons')} hint={t('set.iconsHint')} checked={s.refresh.icons} onChange={v => set(x => ({ ...x, refresh: { ...x.refresh, icons: v } }))} />
|
|
<div className="field"><label htmlFor="parallel">{t('set.parallel')}</label>
|
|
<select id="parallel" className="input" style={{ width: 120 }} value={s.refresh.parallel} onChange={e => set(x => ({ ...x, refresh: { ...x.refresh, parallel: Number(e.target.value) } }))}>
|
|
{[1, 2, 3, 4, 6, 8].map(n => <option key={n} value={n}>{n}</option>)}
|
|
</select><span className="hint">{t('set.parallelHint')}</span></div>
|
|
<div className="actions">
|
|
<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" />{t('action.importApps')}</button>
|
|
<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)} disabled={!!d.portfolioBusy}><Icon name="refresh" />{t('action.readStores')}</button>
|
|
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card id="set-cache" title={t('set.cache')} icon="database" sub={t('set.cacheSub')} className="set-card">
|
|
<div className="form">
|
|
<dl className="kv">
|
|
<dt>{t('set.cacheSize')}</dt><dd>{cache ? `${bytes(cache.file_bytes)} · ${t('set.cacheRows', { count: cache.rows })}` : '–'}</dd>
|
|
<dt>{t('set.cacheOldest')}</dt><dd>{cache?.oldest ? `${date(cache.oldest)} (${ago(cache.oldest)})` : '–'}</dd>
|
|
</dl>
|
|
<div className="field"><span className="legend">{t('set.keep')}</span>
|
|
<Seg value={String(s.cache.keepDays) as '0'} label={t('set.keep')} onChange={v => set(x => ({ ...x, cache: { keepDays: Number(v) as S['cache']['keepDays'] } })).then(loadCache)}
|
|
options={[['7', t('set.days', { n: 7 })], ['30', t('set.days', { n: 30 })], ['90', t('set.days', { n: 90 })], ['365', t('set.year')], ['0', t('set.always')]]} /></div>
|
|
<div className="actions"><button className="btn btn-sm" onClick={clear}><Icon name="trash" />{t('settings.clearCache')}</button></div>
|
|
<p className="small muted">{t('settings.dataText')}</p>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card id="set-appearance" title={t('settings.appearance')} icon="sun" className="set-card">
|
|
<div className="form">
|
|
<div className="field" role="group" aria-label={t('settings.language')}><span className="legend">{t('settings.language')}</span>
|
|
<Seg value={prefs.lang} onChange={lang => update({ ...prefs, lang })} label={t('settings.language')} options={[['system', t('settings.system')], ['en', 'English'], ['tr', 'Türkçe']]} /></div>
|
|
<div className="field" role="group" aria-label={t('settings.theme')}><span className="legend">{t('settings.theme')}</span>
|
|
<Seg value={prefs.theme} onChange={theme => update({ ...prefs, theme })} label={t('settings.theme')} options={[['system', t('settings.system'), 'settings'], ['light', t('settings.light'), 'sun'], ['dark', t('settings.dark'), 'moon']]} /></div>
|
|
<div className="field" role="group" aria-label={t('settings.motion')}><span className="legend">{t('settings.motion')}</span>
|
|
<Seg value={prefs.motion} onChange={motion => update({ ...prefs, motion })} label={t('settings.motion')} options={[['system', t('settings.followSystem')], ['reduce', t('settings.reduce')]]} /></div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card id="set-notifications" title={t('set.notifications')} icon="bell" sub={t('set.notificationsSub')} className="set-card">
|
|
<div className="form">
|
|
<Toggle id="n-review" label={t('set.nReview')} hint={t('set.nReviewHint')} checked={s.notify.reviewState} onChange={v => set(x => ({ ...x, notify: { ...x.notify, reviewState: v } }))} />
|
|
<Toggle id="n-low" label={t('set.nLow')} hint={t('set.nLowHint')} checked={s.notify.lowReviews} onChange={v => set(x => ({ ...x, notify: { ...x.notify, lowReviews: v } }))} />
|
|
<Toggle id="n-crash" label={t('set.nCrash')} hint={t('set.nCrashHint')} checked={s.notify.crash} onChange={v => set(x => ({ ...x, notify: { ...x.notify, crash: v } }))} />
|
|
<div className="actions"><button className="btn btn-sm" onClick={() => api.notify(t('set.testTitle'), t('set.testBody')).catch(e => setError(errorText(e)))}><Icon name="bell" />{t('set.testNotify')}</button></div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card id="set-dependencies" title={t('nav.dependencies')} icon="package" className="set-card" actions={<button className="btn btn-sm" onClick={() => nav.go({ screen: 'dependencies' })}>{t('set.manage')}<Icon name="arrowRight" /></button>}>
|
|
{tools ? (
|
|
<div className="dep-sum">
|
|
{tools.tools.map(x => <span key={x.id} className="row small" style={{ gap: 6 }}><i className={`dot ${x.found ? 'ok' : ''}`} />{x.name}{x.version ? <span className="muted num">{x.version}</span> : null}{x.source === 'app' && <Chip tone="outline">{t('set.appCopy')}</Chip>}</span>)}
|
|
</div>
|
|
) : <p className="small muted">–</p>}
|
|
<p className="small muted" style={{ marginTop: 10 }}>{t('set.depsText')}</p>
|
|
</Card>
|
|
|
|
<Card id="set-cli" title={t('set.cli')} icon="terminal" className="set-card">
|
|
<dl className="kv">
|
|
<dt>{t('settings.app')}</dt><dd>{d.info ? `storebound desktop ${d.info.version}${d.info.debug ? ` (${t('settings.debug')})` : ''} · ${d.info.platform}` : '–'}</dd>
|
|
<dt>CLI</dt><dd>{d.sidecar ? `storebound ${d.sidecar.version}` : d.sidecarError ?? t('foot.cliStarting')}</dd>
|
|
<dt>{t('set.protocol')}</dt><dd>{d.sidecar ? `${d.sidecar.protocol} · ${t('settings.methods', { count: d.sidecar.methods.length })}` : '–'}</dd>
|
|
<dt>{t('set.state')}</dt><dd><span className="row" style={{ gap: 6 }}><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />{d.sidecar?.running ? t('set.running') : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></dd>
|
|
<dt>{t('set.writes')}</dt><dd>{t('set.writesText')}</dd>
|
|
</dl>
|
|
</Card>
|
|
|
|
<Card id="set-privacy" title={t('set.privacy')} icon="shield" className="set-card">
|
|
<ul className="plain-list">
|
|
<li><Icon name="check" />{t('set.p1')}</li>
|
|
<li><Icon name="check" />{t('set.p2')}</li>
|
|
<li><Icon name="check" />{t('set.p3')}</li>
|
|
<li><Icon name="check" />{t('set.p4')}</li>
|
|
</ul>
|
|
<div className="actions" style={{ marginTop: 12 }}>
|
|
<button className="btn btn-sm" onClick={() => api.revealData().catch(e => setError(errorText(e)))}><Icon name="folder" />{t('set.reveal')}</button>
|
|
<button className="btn btn-sm btn-ghost" onClick={() => api.openExternal('https://storebound.dev/').catch(e => setError(errorText(e)))}><Icon name="external" />storebound.dev</button>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card id="set-shortcuts" title={t('settings.shortcuts')} icon="keyboard" className="set-card">
|
|
<dl className="shortcuts">
|
|
<dt>{t('settings.scPalette')}</dt><dd><Kbd keys={[MOD, 'K']} /></dd>
|
|
<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.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>
|
|
</dl>
|
|
</Card>
|
|
|
|
<Card id="set-updates" title={t('set.updates')} icon="download" className="set-card">
|
|
<div className="form">
|
|
<div className="field"><span className="legend">{t('set.channel')}</span>
|
|
<Seg value={s.channel} label={t('set.channel')} onChange={channel => set(x => ({ ...x, channel }))} options={[['stable', t('set.stable')], ['beta', t('set.beta')]]} /></div>
|
|
<Updates />
|
|
<p className="small muted">{t('set.updatesText')}</p>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card id="set-reset" title={t('set.reset')} icon="rotate" className="set-card danger-card">
|
|
<div className="stack">
|
|
<p className="small muted">{t('set.resetText', { accounts: d.accounts.length, studios: d.studios.length, folders: d.projects.length })}</p>
|
|
<div className="actions">
|
|
<button className="btn btn-sm" onClick={nav.openWelcome}><Icon name="sparkle" />{t('action.welcome')}</button>
|
|
<button className="btn btn-sm btn-danger" onClick={reset}><Icon name="rotate" />{t('set.resetButton')}</button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<p className="small muted">{t('set.footer', { n: num(d.apps.length) })}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Settings > Updates: checks on click only; installs after the host's native dialog (the signature is checked there). */
|
|
function Updates() {
|
|
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
|
const [busy, setBusy] = useState<'check' | 'install' | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [got, setGot] = useState<{ received: number; total: number | null } | null>(null);
|
|
useEffect(() => { const un = onUpdateProgress(setGot); return () => { un.then(f => f()); }; }, []);
|
|
const check = async () => {
|
|
setBusy('check'); setError(null);
|
|
try { setInfo(await api.updateCheck()); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
|
|
};
|
|
const install = async (version: string) => {
|
|
setBusy('install'); setError(null);
|
|
try { await api.updateInstall(version); } catch (e) { setError(errorText(e)); } finally { setBusy(null); setGot(null); }
|
|
};
|
|
const channel = info ? t(info.channel === 'beta' ? 'set.beta' : 'set.stable') : '';
|
|
return (
|
|
<div className="stack" style={{ gap: 8 }}>
|
|
{error && <Notice tone="bad">{error}</Notice>}
|
|
{info && !info.available && <p className="small">{t('set.updateNone', { version: info.current, channel })}</p>}
|
|
{info?.available && <p className="small"><b>{t('set.updateAvailable', { version: info.available })}</b>{info.notes ? ` ${info.notes}` : ''}</p>}
|
|
{busy === 'install' && got && <p className="small muted" role="status">{t('set.updateInstalling', { done: got.total ? `${Math.round((got.received / got.total) * 100)}%` : bytes(got.received) })}</p>}
|
|
<div className="actions">
|
|
<button className="btn btn-sm" disabled={!!busy} onClick={check}><Icon name="refresh" />{busy === 'check' ? t('set.updateChecking') : t('set.updateCheck')}</button>
|
|
{info?.available && <button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => install(info.available!)}><Icon name="download" />{t('set.updateInstall', { version: info.available })}</button>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|