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:
Bonchellon
2026-07-07 14:50:46 +03:00
parent a67c171315
commit 15140918ad
4 changed files with 217 additions and 35 deletions

View File

@ -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&nbsp;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&nbsp;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&nbsp;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 — dont 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(() => {