Add animated startup splash + live server transfer dialog
Splash: pulsing logo, client->server packet-flow animation, cycling status steps and a shimmer bar, shown for ~1s while the session restores. Transfer progress: the backend now streams p4 sync/submit stdout line by line (throttled ~25fps) and emits p4-transfer events; the frontend shows a P4V-style progress dialog (op title, current file, file count, animated bar) that only appears once an operation runs past ~400ms. Replaces the old submit-only modal. All new strings translated (5 langs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
119
src/App.tsx
119
src/App.tsx
@ -80,26 +80,19 @@ export default function App() {
|
||||
const [zoom, setZoom] = useZoom();
|
||||
const saved = useMemo(() => loadSession(), []);
|
||||
|
||||
// try to restore the last session from the on-disk p4 ticket
|
||||
// try to restore the last session from the on-disk p4 ticket.
|
||||
// keep the splash on screen for at least a beat so the intro animation reads.
|
||||
useEffect(() => {
|
||||
if (!saved) { setPhase("connect"); return; }
|
||||
if (!saved) { const id = setTimeout(() => setPhase("connect"), 900); return () => clearTimeout(id); }
|
||||
const min = new Promise<void>((r) => setTimeout(r, 1000));
|
||||
p4.restore(saved.server, saved.user, saved.client)
|
||||
.then((i) => { setInfo(i); setSession(saved); setPhase("main"); })
|
||||
.catch(() => setPhase("connect"));
|
||||
.then(async (i) => { await min; setInfo(i); setSession(saved); setPhase("main"); })
|
||||
.catch(async () => { await min; setPhase("connect"); });
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
let content;
|
||||
if (phase === "loading")
|
||||
content = (
|
||||
<div className="win">
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte Depot <span>— Perforce</span></div></div>
|
||||
<button className="theme-btn" onClick={toggleTheme} title="Тема">{light ? I.sun : I.moon}</button>
|
||||
<WinControls />
|
||||
</div>
|
||||
<div className="overlay"><div className="nofile"><span className="ldr" /><span>Восстановление сессии…</span></div></div>
|
||||
</div>
|
||||
);
|
||||
content = <Splash light={light} toggleTheme={toggleTheme} />;
|
||||
else if (phase === "connect")
|
||||
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
|
||||
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
|
||||
@ -111,6 +104,42 @@ export default function App() {
|
||||
return <>{content}<UpdateBanner /></>;
|
||||
}
|
||||
|
||||
/* ---------------- animated startup splash ---------------- */
|
||||
function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) {
|
||||
const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")];
|
||||
const [step, setStep] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setStep((s) => s + 1), 750);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<div className="win">
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte Depot <span>— Perforce</span></div></div>
|
||||
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
|
||||
<WinControls />
|
||||
</div>
|
||||
<div className="overlay">
|
||||
<div className="splash">
|
||||
<div className="splash-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.4"/></svg>
|
||||
</div>
|
||||
<div className="flow">
|
||||
<div className="node"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></div>
|
||||
<div className="wire">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => <span key={i} className="packet" style={{ animationDelay: `${i * 0.28}s` }} />)}
|
||||
</div>
|
||||
<div className="node"><svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6"/></svg></div>
|
||||
</div>
|
||||
<div className="splash-title">Exbyte Depot</div>
|
||||
<div className="splash-step">{steps[Math.min(step, steps.length - 1)]}</div>
|
||||
<div className="splash-bar"><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- connect screen ---------------- */
|
||||
function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; toggleTheme: () => void; initial: Session | null; onDone: (i: P4Info, s: Session) => void }) {
|
||||
const [server, setServer] = useState(initial?.server || "ssl:pf.exbytestudios.com:1666");
|
||||
@ -197,6 +226,7 @@ type ModalState =
|
||||
| { kind: "about" };
|
||||
|
||||
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
|
||||
type Transfer = { op: "sync" | "submit"; count: number; file?: string };
|
||||
|
||||
function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
|
||||
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
|
||||
@ -211,7 +241,6 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [descBody, setDescBody] = useState(""); // extended description (optional)
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false); // background reconcile in progress
|
||||
const [pushing, setPushing] = useState<{ done: number; total: number } | null>(null); // submit progress
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
|
||||
const [history, setHistory] = useState<Change[]>([]);
|
||||
@ -221,6 +250,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
|
||||
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
|
||||
const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
|
||||
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
|
||||
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
@ -296,6 +327,23 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
}).then((u) => (un = u));
|
||||
return () => un?.();
|
||||
}, []);
|
||||
// live file-transfer progress (Get Latest / Submit) streamed from the backend
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined;
|
||||
listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
|
||||
const p = e.payload;
|
||||
if (p.done) setTransfer(null);
|
||||
else setTransfer({ op: p.op, count: p.count, file: p.file });
|
||||
}).then((u) => (un = u));
|
||||
return () => un?.();
|
||||
}, []);
|
||||
// only surface the progress dialog once a transfer has run for a moment
|
||||
const xferActive = transfer !== null;
|
||||
useEffect(() => {
|
||||
if (!xferActive) { setShowXfer(false); return; }
|
||||
const id = setTimeout(() => setShowXfer(true), 400);
|
||||
return () => clearTimeout(id);
|
||||
}, [xferActive]);
|
||||
useEffect(() => {
|
||||
const close = () => setOpenMenu(null);
|
||||
document.addEventListener("click", close);
|
||||
@ -456,15 +504,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
const n = pending.length;
|
||||
if (!(await confirm({ title: t("Submit — send to the server"), body: t("{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.", { n }), confirm: t("Submit {n}", { n }) }))) return;
|
||||
setBusy(true);
|
||||
setPushing({ done: 0, total: n });
|
||||
const errors: string[] = [];
|
||||
let done = 0;
|
||||
for (const c of pending) {
|
||||
try { await p4.submitChange(c.change || ""); }
|
||||
catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); }
|
||||
setPushing({ done: ++done, total: n });
|
||||
}
|
||||
setPushing(null);
|
||||
if (errors.length) flash(errors.join(" · "), true);
|
||||
else flash(t("Sent to the server: {n} changelist(s).", { n }));
|
||||
await refresh();
|
||||
@ -639,7 +683,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
|
||||
<div className="statusbar">
|
||||
<span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
|
||||
<span className={"stdot" + ((busy || scanning || !!pushing) ? " busy" : "")}>{(busy || scanning || !!pushing) ? <span className="ldr sm" /> : I.check}</span>
|
||||
<span className={"stdot" + ((busy || scanning || !!transfer) ? " busy" : "")}>{(busy || scanning || !!transfer) ? <span className="ldr sm" /> : I.check}</span>
|
||||
<span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
|
||||
</span>
|
||||
<button className="stlog" onClick={() => setLogOpen((o) => !o)} title="Ctrl+L">{I.log}<span>Log</span></button>
|
||||
@ -662,16 +706,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
|
||||
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
|
||||
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
|
||||
{pushing && (
|
||||
<div className="modal-back">
|
||||
<div className="push-modal">
|
||||
<div className="push-title"><span className="ldr" />{t("Submit — send to the server")}</div>
|
||||
<div className="push-count">{t("Changelist {a} of {b}", { a: pushing.done, b: pushing.total })}</div>
|
||||
<div className="push-track"><div className="push-fill" style={{ width: `${Math.round((pushing.done / pushing.total) * 100)}%` }} /></div>
|
||||
<div className="push-hint">{t("Large assets take longer to upload — don’t close the window.")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showXfer && transfer && <TransferDialog transfer={transfer} />}
|
||||
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
|
||||
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
|
||||
</div>
|
||||
@ -838,6 +873,30 @@ function LogPanel({ logs, onClose, onClear }: { logs: LogEntry[]; onClose: () =>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- server transfer progress (P4V-style) ---------------- */
|
||||
function TransferDialog({ transfer }: { transfer: Transfer }) {
|
||||
const up = transfer.op === "submit";
|
||||
return (
|
||||
<div className="modal-back xfer-back">
|
||||
<div className="xfer">
|
||||
<div className="xfer-head">
|
||||
<span className={"xfer-ic" + (up ? " up" : " down")}>
|
||||
{up
|
||||
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</span>
|
||||
<div className="xfer-ttl">
|
||||
<b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
|
||||
<span>{t("{n} files", { n: transfer.count })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>
|
||||
<div className="xfer-bar"><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- confirm modal ---------------- */
|
||||
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user