storebound/apps/desktop/src/screens/AppFolders.tsx

181 lines
13 KiB
TypeScript

// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
import { type MenuInput, useContextMenu } from '../ui/ContextMenu';
import { copyToast } from '../ui/app-menu';
import { failureText } from '../lib/results';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
import { devSwitch } from '../dev/switches';
import { t } from '../i18n';
import { shortPath, tildeText } from '../paths';
import { useNav } from '../nav';
import { useData } from '../store';
import { Icon, StoreGlyph } from '../ui/icons';
import { useFolderActions } from '../ui/FolderMissing';
import { AppIcon, Empty, Notice, Seg, StoreBadge } from '../ui/kit';
import { PageHeader, Section, StatusPill, tintStyle, useTint } from '../ui/ds';
import '../styles/folders.css';
import { checkState } from './Portfolio';
type Profile = 'app' | 'game' | 'subscription-app';
interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string }
/** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */
function Tinted({ src, name, children, menu }: { src: string | null | undefined; name: string; children: ReactNode; menu: () => MenuInput }) {
const hue = useTint(src, name);
const cm = useContextMenu();
return <div className="li fold-card" style={tintStyle(hue)} {...cm(menu)}>{children}</div>;
}
function LocateButton({ project }: { project: Project }) {
const { busy, locate } = useFolderActions(project);
return <button className="btn btn-sm btn-primary" onClick={locate} disabled={busy} title={t('folder.locateHint')}><Icon name="search" />{t('folder.locate')}</button>;
}
export function AppFolders() {
const d = useData();
const nav = useNav();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [run, setRun] = useState<Run | null>(null);
const [profile, setProfile] = useState<Profile>('app');
const logRef = useRef<HTMLPreElement>(null);
useEffect(() => {
const un = onProgress(p => { if (p.line !== undefined) setRun(r => (r && p.job === r.job ? { ...r, lines: [...r.lines.slice(-400), p.line!] } : r)); });
return () => { un.then(f => f()); };
}, []);
useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); }, [run?.lines.length]);
const act = async (key: string, fn: () => Promise<void>) => {
setBusy(key); setError(null);
try { await fn(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
};
const add = () => act('add', async () => { const p = await api.addProject(); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.added', { name: p.name }) }); } });
const init = () => act('init', async () => { const p = await api.initProject(profile); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.started', { name: p.name }), detail: t('folders.startedDetail') }); } });
const fastlane = () => act('fastlane', async () => { const p = await api.importFastlane(profile); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.imported', { name: p.name }), detail: t('folders.importedDetail') }); } });
const remove = (p: Project) => act(`rm-${p.id}`, async () => { await api.removeProject(p.id); await d.reload(); });
const setAcc = (p: Project, play: string | null, apple: string | null) => act(`acc-${p.id}`, async () => { await api.setProjectAccounts(p.id, play, apple); await d.reload(); });
const check = (p: Project) => act(`check-${p.id}`, async () => {
const job = newJob('check');
setRun({ title: t('folders.checkTitle'), project: p.name, job, lines: [] });
try { const r = await api.call('check', p.id, undefined, job); setRun(x => x && { ...x, result: r }); } catch (e) { setRun(x => x && { ...x, error: errorText(e) }); }
});
const gallery = (p: Project) => act(`gallery-${p.id}`, async () => {
const job = newJob('gallery');
setRun({ title: t('folders.galleryTitle'), project: p.name, job, lines: [] });
try { const o = await api.writeWithConfirm('shots.gallery', p.id, {}, job); setRun(x => x && { ...x, outcome: o, result: o.result ?? o.dryRun }); } catch (e) { setRun(x => x && { ...x, error: errorText(e) }); }
});
// Dev builds only: VITE_AUTORUN=gallery starts the confirm flow on the first app once (checks of the native dialog).
const autorun = useRef(false);
useEffect(() => {
const first = d.projects[0];
if (devSwitch('autorun') !== 'gallery' || !first || autorun.current) return;
autorun.current = true;
gallery(first);
}, [d.projects]); // eslint-disable-line react-hooks/exhaustive-deps
const play = d.accounts.filter(a => a.kind === 'play');
const apple = d.accounts.filter(a => a.kind === 'apple');
const status = !run ? '' : run.error ? t('run.failed') : !run.result ? t('run.running') : run.outcome
? (run.outcome.confirmed ? t('run.applied') : run.outcome.stage === 'dialog' ? t('run.cancelled') : t('run.dryFailed'))
: run.result.ok ? t('run.done') : failureText(run.result);
return (
<div className="page scr-folders">
<PageHeader icon="folder" eyebrow={t('v2cfg.workspace')} title={t('nav.apps')} subtitle={t('folders.lead')} actions={<button className="btn btn-primary" onClick={add} disabled={!!busy}><Icon name="folderPlus" />{t('action.addFolder')}</button>} />
{error && <Notice tone="bad">{error}</Notice>}
{!d.projects.length ? (
<Section pad={false}><Empty illo="apps" title={t('folders.emptyTitle')} actions={<>
<button className="btn" onClick={fastlane} disabled={!!busy}><Icon name="download" />{t('folders.fastlane')}</button>
<button className="btn" onClick={init} disabled={!!busy}><Icon name="sparkle" />{t('folders.startShort')}</button>
<button className="btn btn-primary" onClick={add} disabled={!!busy}><Icon name="folderPlus" />{t('action.addFolder')}</button>
</>}>{t('folders.emptyText')}</Empty></Section>
) : (
<Section title={t('folders.list')} icon="folder" sub={t('folders.listSub', { count: d.projects.length })} pad={false}>
<div className="list">
{d.projects.map(p => {
const e = d.statusOf(p);
const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e);
return (
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name} menu={() => [
!p.missing && { label: t('folders.open'), icon: 'arrowRight', onSelect: () => nav.go({ screen: 'app', appId: p.id, tab: 'releases' }) },
!p.missing && { label: t('folders.reveal'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(x => setError(errorText(x))); } },
'sep',
!p.missing && { label: t('folders.check'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => check(p) },
!p.missing && { label: t('folders.gallery'), icon: 'image', disabled: busy ? t('cm.busy') : false, onSelect: () => gallery(p) },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
{ label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) },
e?.app?.platforms.ios?.bundleId && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: e.app.platforms.ios.bundleId, onSelect: () => copyToast(d, e.app!.platforms.ios!.bundleId) },
e?.app?.platforms.android?.packageName && { label: t('cm.copyPackage'), icon: 'tag', shortcut: e.app.platforms.android.packageName, onSelect: () => copyToast(d, e.app!.platforms.android!.packageName) },
] },
'sep',
{ label: t('folders.remove', { name: p.name }), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => remove(p) },
]}>
<AppIcon name={p.name} src={d.icons[p.id]} className="fold-icon" />
<div className="li-main">
<span className="row" style={{ gap: 8 }}><span className="li-title">{e?.app?.name ?? p.name}</span><StatusPill tone={st.tone}>{st.text}</StatusPill></span>
<span className="li-sub mono-id selectable" title={p.path}>{shortPath(p.path, d.info?.home)}</span>
<span className="row" style={{ gap: 6, marginTop: 6 }}>
<StoreBadge store="ios" id={e?.app?.platforms.ios?.bundleId} off={!!e && !e.app?.platforms.ios} />
<StoreBadge store="android" id={e?.app?.platforms.android?.packageName} off={!!e && !e.app?.platforms.android} />
</span>
</div>
<div className="stack fold-accs" style={{ gap: 6 }}>
<label className="acc-pick" title="Google Play"><StoreGlyph store="android" />
<select className="input" aria-label={t('folders.playFor', { name: p.name })} value={p.playAccount ?? ''} onChange={ev => setAcc(p, ev.target.value || null, p.appleAccount)} disabled={!!busy}>
<option value="">{t('folders.noPlay')}</option>
{play.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select></label>
<label className="acc-pick" title="App Store Connect"><StoreGlyph store="ios" />
<select className="input" aria-label={t('folders.appleFor', { name: p.name })} value={p.appleAccount ?? ''} onChange={ev => setAcc(p, p.playAccount, ev.target.value || null)} disabled={!!busy}>
<option value="">{t('folders.noApple')}</option>
{apple.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select></label>
</div>
<div className="actions fold-acts">
{p.missing ? <LocateButton project={p} /> : <>
<button className="btn btn-sm" onClick={() => nav.go({ screen: 'app', appId: p.id, tab: 'releases' })} title={t('folders.openApp')}><Icon name="arrowRight" />{t('folders.open')}</button>
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => api.reveal(p.id).catch(e => setError(errorText(e)))} aria-label={t('folders.reveal')} title={t('folders.reveal')}><Icon name="eye" /></button>
<button className="btn btn-sm" onClick={() => check(p)} disabled={!!busy}><Icon name="shield" />{t('folders.check')}</button>
<button className="btn btn-sm" onClick={() => gallery(p)} disabled={!!busy} title={t('folders.galleryHint')}><Icon name="image" />{t('folders.gallery')}</button>
</>}
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => remove(p)} disabled={!!busy} aria-label={t('folders.remove', { name: p.name })} title={t('folders.remove', { name: p.name })}><Icon name="trash" /></button>
</div>
</Tinted>
);
})}
</div>
</Section>
)}
<Section title={t('folders.newTitle')} icon="sparkle" sub={t('folders.newSub')}>
<div className="row between" style={{ gap: 16, flexWrap: 'wrap' }}>
<div className="stack" style={{ gap: 8 }}>
<Seg value={profile} onChange={setProfile} label={t('folders.profile')} options={[['app', t('profile.app')], ['game', t('profile.game')], ['subscription-app', t('profile.subscription-app')]]} />
<span className="small muted">{t(`folders.profileHint.${profile}` as 'folders.profileHint.app')}</span>
</div>
<div className="actions">
<button className="btn" onClick={fastlane} disabled={!!busy} title={t('folders.fastlaneHint')}><Icon name="download" />{t('folders.fastlane')}</button>
<button className="btn btn-primary" onClick={init} disabled={!!busy}><Icon name="folderPlus" />{t('folders.chooseAndStart')}</button>
</div>
</div>
<p className="small muted act-note"><Icon name="info" />{t('folders.newNote')}</p>
</Section>
{run && (
<Section title={`${run.title} · ${run.project}`} icon="terminal" sub={<>{status}{run.result && <> · <code className="selectable">{run.result.command}</code></>}</>}
actions={<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setRun(null)} aria-label={t('common.close')}><Icon name="x" /></button>}>
<div className="stack">
{run.error && <Notice tone="bad">{run.error}</Notice>}
<pre className="log selectable" ref={logRef} aria-live="polite">{tildeText(run.result?.output ?? run.lines.join('\n'), d.info?.home) || ' '}</pre>
</div>
</Section>
)}
</div>
);
}