flow / src /components /MobileShell.tsx
mrfakename's picture
Initial Flow app
fb57ab3 verified
Raw History Blame Contribute Delete
17.8 kB
import { Button } from "@cloudflare/kumo";
import { ArrowUUpLeftIcon, CaretRightIcon, CheckCircleIcon, DownloadSimpleIcon, GraphIcon, ImagesIcon, PlayIcon, PlusIcon, RowsIcon, ShareNetworkIcon, SlidersHorizontalIcon, SparkleIcon, SquaresFourIcon, WarningCircleIcon, XIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getDef } from "../lib/registry";
import type { CanvasNode, FileRef, OutputValue } from "../lib/types";
import { OutputView } from "./OutputView";
import { useCanvasStore } from "../store/useCanvasStore";
import { useCopilotStore } from "../store/useCopilotStore";
export type MobilePanel = "library" | "inspector" | "copilot" | "results" | null;
/** Which panel is showing on a phone. Only one fits, so the last one opened wins. */
export function useMobilePanel(active: boolean): MobilePanel {
const leftOpen = useCanvasStore((s) => s.leftOpen);
const rightOpen = useCanvasStore((s) => s.rightOpen);
const copilotOpen = useCopilotStore((s) => s.open);
const resultsOpen = useCanvasStore((s) => s.resultsOpen);
const setLeftOpen = useCanvasStore((s) => s.setLeftOpen);
const setRightOpen = useCanvasStore((s) => s.setRightOpen);
const setResultsOpen = useCanvasStore((s) => s.setResultsOpen);
const setCopilotOpen = useCopilotStore((s) => s.setOpen);
const prev = useRef({ leftOpen, rightOpen, copilotOpen, resultsOpen });
useEffect(() => {
if (!active) return;
const p = prev.current;
prev.current = { leftOpen, rightOpen, copilotOpen, resultsOpen };
const opened =
(resultsOpen && !p.resultsOpen && "results") || (leftOpen && !p.leftOpen && "library") || (rightOpen && !p.rightOpen && "inspector") || (copilotOpen && !p.copilotOpen && "copilot") || null;
if (!opened) return;
if (opened !== "library" && leftOpen) setLeftOpen(false);
if (opened !== "inspector" && rightOpen) setRightOpen(false);
if (opened !== "copilot" && copilotOpen) setCopilotOpen(false);
if (opened !== "results" && resultsOpen) setResultsOpen(false);
}, [active, leftOpen, rightOpen, copilotOpen, resultsOpen, setLeftOpen, setRightOpen, setCopilotOpen, setResultsOpen]);
if (!active) return null;
return resultsOpen ? "results" : copilotOpen ? "copilot" : rightOpen ? "inspector" : leftOpen ? "library" : null;
}
/** Full-height sheet over the work area; the header carries the close button. */
export function MobileSheet({ panel, title, children }: { panel: MobilePanel; title: string; children: React.ReactNode }) {
const setLeftOpen = useCanvasStore((s) => s.setLeftOpen);
const setRightOpen = useCanvasStore((s) => s.setRightOpen);
const setCopilotOpen = useCopilotStore((s) => s.setOpen);
const setResultsOpen = useCanvasStore((s) => s.setResultsOpen);
const close = () => {
setLeftOpen(false);
setRightOpen(false);
setCopilotOpen(false);
setResultsOpen(false);
};
return (
<AnimatePresence initial={false}>
{panel && (
<motion.section
key={panel}
className="m-sheet"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
aria-label={title}
>
<header className="m-sheet__head">
<span className="truncate">{title}</span>
<button type="button" className="m-sheet__close" onClick={close} aria-label="Close">
<XIcon size={14} weight="bold" />
</button>
</header>
<div className="min-h-0 flex-1">{children}</div>
</motion.section>
)}
</AnimatePresence>
);
}
/** Bottom tab bar: what the side toggles and view switch do on a desktop. */
export function MobileBar({ panel }: { panel: MobilePanel }) {
const view = useCanvasStore((s) => s.view);
const setView = useCanvasStore((s) => s.setView);
const setLeftOpen = useCanvasStore((s) => s.setLeftOpen);
const setRightOpen = useCanvasStore((s) => s.setRightOpen);
const setInspectorTab = useCanvasStore((s) => s.setInspectorTab);
const openPalette = useCanvasStore((s) => s.openPalette);
const selectedCount = useCanvasStore((s) => s.nodes.filter((n) => n.selected).length);
const running = useCanvasStore((s) => s.run.running);
const queued = useCanvasStore((s) => s.queue.length);
const setCopilotOpen = useCopilotStore((s) => s.setOpen);
const setResultsOpen = useCanvasStore((s) => s.setResultsOpen);
const closeAll = () => {
setLeftOpen(false);
setRightOpen(false);
setCopilotOpen(false);
setResultsOpen(false);
};
const toggle = (which: Exclude<MobilePanel, null>) => {
if (panel === which) return closeAll();
if (which === "library") setLeftOpen(true);
else if (which === "results") setResultsOpen(true);
else if (which === "inspector") {
setInspectorTab("properties");
setRightOpen(true);
} else setCopilotOpen(true);
};
const ViewIcon = view === "canvas" ? GraphIcon : RowsIcon;
return (
<nav className="m-bar" aria-label="Mobile navigation">
<button type="button" className="m-bar__btn" data-active={panel === "library" || undefined} onClick={() => toggle("library")}>
<SquaresFourIcon size={20} weight={panel === "library" ? "fill" : "regular"} />
<span>Library</span>
</button>
<button
type="button"
className="m-bar__btn"
data-active={!panel || undefined}
onClick={() => {
if (panel) closeAll();
else setView(view === "canvas" ? "pipeline" : "canvas");
}}
title={panel ? "Back to the workflow" : `Switch to ${view === "canvas" ? "pipeline" : "canvas"} view`}
>
<ViewIcon size={20} weight={!panel ? "fill" : "regular"} />
<span>{view === "canvas" ? "Canvas" : "Pipeline"}</span>
</button>
<button type="button" className="m-bar__btn m-bar__btn--add" onClick={() => openPalette(null)} aria-label="Add a node">
<span className="m-bar__add">
<PlusIcon size={20} weight="bold" />
</span>
<span>Add</span>
</button>
<button type="button" className="m-bar__btn" data-active={panel === "inspector" || undefined} onClick={() => toggle("inspector")}>
<span className="relative">
<SlidersHorizontalIcon size={20} weight={panel === "inspector" ? "fill" : "regular"} />
{(selectedCount > 0 || running || queued > 0) && <i className="m-bar__dot" />}
</span>
<span>{selectedCount > 0 ? "Node" : "Inspect"}</span>
</button>
<button type="button" className="m-bar__btn" data-active={panel === "copilot" || undefined} onClick={() => toggle("copilot")}>
<SparkleIcon size={20} weight={panel === "copilot" ? "fill" : "regular"} />
<span>Copilot</span>
</button>
</nav>
);
}
/* Run strip -------------------------------------------------------------- */
const fmtSecs = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`);
/** Under the top bar while a run is active: the current step, its ETA, and a tap to jump to it. */
export function RunStrip() {
const run = useCanvasStore((s) => s.run);
const current = useCanvasStore((s) => (s.run.currentNodeId ? s.nodes.find((n) => n.id === s.run.currentNodeId) : undefined));
const runningNode = useCanvasStore((s) => s.nodes.find((n) => n.data.status === "running"));
const failedNode = useCanvasStore((s) => s.nodes.find((n) => n.data.status === "error"));
const queued = useCanvasStore((s) => s.queue.length);
const selectNodes = useCanvasStore((s) => s.selectNodes);
const setResultsOpen = useCanvasStore((s) => s.setResultsOpen);
const [, tick] = useState(0);
// The "Done" state hides itself after a few seconds.
const finished = !run.running && run.finishedAt !== null && Date.now() - run.finishedAt < 6000;
useEffect(() => {
if (run.running) {
const t = setInterval(() => tick((x) => x + 1), 1000);
return () => clearInterval(t);
}
if (run.finishedAt === null) return;
const t = setTimeout(() => tick((x) => x + 1), 6100);
return () => clearTimeout(t);
}, [run.running, run.finishedAt]);
const show = run.running || finished;
const node = runningNode ?? current;
const title = node ? node.data.title ?? getDef(node.data.defType).title : null;
const frac = run.total ? Math.min(1, (run.completed + (runningNode?.data.progress ?? 0)) / run.total) : 0;
const jump = (id: string) => {
selectNodes([id]);
window.dispatchEvent(new CustomEvent("gr-canvas:focus-node", { detail: id }));
};
return (
<AnimatePresence initial={false}>
{show && (
<motion.button
type="button"
className="run-strip"
data-state={run.running ? "running" : run.ok ? "done" : "failed"}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 36, opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
onClick={() => {
if (run.running) {
if (node) jump(node.id);
} else if (run.ok) setResultsOpen(true);
else if (failedNode) jump(failedNode.id);
}}
>
{run.running ? (
<>
<span className="run-strip__spinner" aria-hidden />
<span className="run-strip__main truncate">{title ?? "Starting…"}</span>
<span className="run-strip__meta">
{runningNode?.data.statusText ?? (run.total ? `${Math.min(run.completed + 1, run.total)}/${run.total}` : fmtSecs(Date.now() - run.startedAt))}
{queued > 0 && ` · +${queued} queued`}
</span>
<span className="run-strip__bar" style={{ transform: `scaleX(${frac})` }} aria-hidden />
</>
) : run.ok ? (
<>
<CheckCircleIcon size={16} weight="fill" />
<span className="run-strip__main truncate">Done in {fmtSecs(Date.now() - run.startedAt)}</span>
<span className="run-strip__meta">
Results <CaretRightIcon size={11} weight="bold" />
</span>
</>
) : (
<>
<WarningCircleIcon size={16} weight="fill" />
<span className="run-strip__main truncate">{failedNode ? `${failedNode.data.title ?? getDef(failedNode.data.defType).title} failed` : "Run failed"}</span>
<span className="run-strip__meta">
Show <CaretRightIcon size={11} weight="bold" />
</span>
</>
)}
</motion.button>
)}
</AnimatePresence>
);
}
/* Results sheet ----------------------------------------------------------- */
const isRef = (v: unknown): v is FileRef => !!v && typeof v === "object" && typeof (v as FileRef).url === "string";
const filesOf = (v: OutputValue | undefined): FileRef[] => (isRef(v) ? [v] : Array.isArray(v) ? (v.filter(isRef) as FileRef[]) : []);
const hasValue = (v: OutputValue | undefined) => v !== undefined && v !== null && v !== "";
async function fetchFile(ref: FileRef): Promise<File | null> {
try {
const r = await fetch(ref.url);
if (!r.ok) return null;
const blob = await r.blob();
const name = ref.name ?? ref.url.split("/").pop()?.split("?")[0] ?? "output";
return new File([blob], name, { type: ref.mime || blob.type });
} catch {
return null;
}
}
async function shareRef(ref: FileRef, title: string, setToast: (t: { text: string } | null) => void) {
const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean };
if (typeof nav.share !== "function") {
try {
await navigator.clipboard.writeText(ref.url);
setToast({ text: "Link copied" });
} catch {
window.open(ref.url, "_blank", "noopener");
}
return;
}
const file = await fetchFile(ref);
try {
if (file && nav.canShare?.({ files: [file] })) await nav.share({ files: [file], title });
else await nav.share({ title, url: ref.url });
} catch {
/* user dismissed */
}
}
async function saveRef(ref: FileRef) {
const file = await fetchFile(ref);
if (!file) return window.open(ref.url, "_blank", "noopener");
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
function ResultCard({ node }: { node: CanvasNode }) {
const def = getDef(node.data.defType);
const setToast = useCanvasStore((s) => s.setToast);
const title = node.data.title ?? def.title;
const ports = def.outputs.filter((p) => hasValue(node.data.outputs?.[p.id]));
return (
<section className="m-result">
<h3 className="m-result__title">{title}</h3>
{ports.map((p) => {
const value = node.data.outputs?.[p.id];
const files = filesOf(value);
return (
<div key={p.id} className="m-result__port">
{ports.length > 1 && <div className="m-result__label">{p.label || p.id}</div>}
<OutputView value={value} type={p.type} />
{files.length > 0 && (
<div className="m-result__actions">
{files.slice(0, 4).map((f, i) => (
<span key={i} className="flex gap-1.5">
<Button size="sm" variant="secondary" icon={ShareNetworkIcon} onClick={() => void shareRef(f, title, setToast)}>
Share{files.length > 1 ? ` ${i + 1}` : ""}
</Button>
<Button size="sm" variant="ghost" icon={DownloadSimpleIcon} onClick={() => void saveRef(f)}>
Save
</Button>
</span>
))}
</div>
)}
{files.length === 0 && typeof value === "string" && (
<div className="m-result__actions">
<Button
size="sm"
variant="secondary"
icon={ShareNetworkIcon}
onClick={() => {
if (typeof navigator.share === "function") void navigator.share({ title, text: value }).catch(() => undefined);
else void navigator.clipboard.writeText(value).then(() => setToast({ text: "Copied" }));
}}
>
Share
</Button>
</div>
)}
</div>
);
})}
</section>
);
}
/** Every output of the last run, full width; final outputs first. */
export function ResultsView() {
const nodes = useCanvasStore((s) => s.nodes);
const edges = useCanvasStore((s) => s.edges);
const order = useCanvasStore((s) => s.order);
const running = useCanvasStore((s) => s.run.running);
const enqueue = useCanvasStore((s) => s.enqueue);
const setResultsOpen = useCanvasStore((s) => s.setResultsOpen);
const items = useMemo(() => {
const feeds = new Set(edges.map((e) => e.source));
const rank = new Map(order.map((id, i) => [id, i]));
return nodes
.filter((n) => {
const def = getDef(n.data.defType);
return !def.emits && def.outputs.some((p) => hasValue(n.data.outputs?.[p.id]));
})
.sort((a, b) => Number(feeds.has(a.id)) - Number(feeds.has(b.id)) || (rank.get(b.id) ?? 0) - (rank.get(a.id) ?? 0));
}, [nodes, edges, order]);
if (items.length === 0)
return (
<div className="m-results__empty">
<ImagesIcon size={28} className="text-kumo-placeholder" />
<p className="text-sm text-kumo-subtle">{running ? "Running… results land here." : "No results yet."}</p>
{!running && nodes.length > 0 && (
<Button size="sm" variant="primary" icon={PlayIcon} onClick={() => enqueue()}>
Run workflow
</Button>
)}
{!running && nodes.length === 0 && (
<Button size="sm" variant="primary" icon={PlusIcon} onClick={() => { setResultsOpen(false); useCanvasStore.getState().openPalette(null); }}>
Add a node
</Button>
)}
</div>
);
return (
<div className="m-results">
{items.map((n) => (
<ResultCard key={n.id} node={n} />
))}
</div>
);
}
/* Toast -------------------------------------------------------------------- */
/** Bottom toast with an optional action ("Removed X · Undo"). */
export function ToastHost() {
const toast = useCanvasStore((s) => s.toast);
const setToast = useCanvasStore((s) => s.setToast);
return (
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
className="toast"
role="status"
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98, transition: { duration: 0.15 } }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<span className="truncate">{toast.text}</span>
{toast.action && (
<button type="button" className="toast__act" onClick={toast.action.run}>
{toast.action.label === "Undo" && <ArrowUUpLeftIcon size={13} weight="bold" />}
{toast.action.label}
</button>
)}
<button type="button" className="toast__close" aria-label="Dismiss" onClick={() => setToast(null)}>
<XIcon size={12} weight="bold" />
</button>
</motion.div>
)}
</AnimatePresence>
);
}