166 lines
12 KiB
TypeScript
166 lines
12 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 { 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 }: { src: string | null | undefined; name: string; children: ReactNode }) {
|
|
const hue = useTint(src, name);
|
|
return <div className="li fold-card" style={tintStyle(hue)}>{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" onClick={fastlane} disabled={!!busy} title={t('folders.fastlaneHint')}><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></>} />
|
|
{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}>
|
|
<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>
|
|
);
|
|
}
|