Spaces:
Running
Running
Download src/components/MobileShell.tsx from mrfakename/flow: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/spaces/mrfakename/flow/resolve/main/src/components/MobileShell.tsx
- Command line
-
hf download hf://spaces/mrfakename/flow/src/components/MobileShell.tsx
-
curl -L -o MobileShell.tsx https://huggingface.co/spaces/mrfakename/flow/resolve/main/src/components/MobileShell.tsx
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> | |
| ); | |
| } | |