feat(web): initialize Next.js project with Tailwind CSS and TypeScript setup

This commit is contained in:
Shreyas Kapale
2026-05-13 22:15:08 +05:30
committed by patel-lyzr
commit 2e94e6bdf6
84 changed files with 11063 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
dist
# Note: out/ is the static-export target embedded by Go (web/embed.go).
# We ignore everything inside but keep the directory + .gitkeep so the
# Go embed compiles before the npm build has run.
/out/*
!/out/.gitkeep
.env*.local
.DS_Store
next-env.d.ts
*.tsbuildinfo
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { RefreshCw, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api, type ExecutionStatus } from "@/lib/api";
export default function ExecutionPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
<ExecutionView />
</Suspense>
);
}
function statusVariant(s?: string) {
switch ((s || "").toLowerCase()) {
case "success":
case "completed":
return "success" as const;
case "running":
case "pending":
return "secondary" as const;
case "failed":
case "error":
return "destructive" as const;
case "waiting":
case "paused":
return "warning" as const;
default:
return "outline" as const;
}
}
function ExecutionView() {
const params = useSearchParams();
const id = params.get("id") ?? "";
const [status, setStatus] = useState<ExecutionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [polling, setPolling] = useState(true);
// resume form
const [awakeable, setAwakeable] = useState("");
const [data, setData] = useState(`{"approved": true}`);
const [resuming, setResuming] = useState(false);
async function refresh() {
if (!id) return;
try {
const s = await api.getExecution(id);
setStatus(s);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
}
}
useEffect(() => {
refresh();
if (!polling) return;
const t = setInterval(() => {
refresh();
}, 2000);
return () => clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, polling]);
async function onResume() {
setResuming(true);
setError(null);
try {
let payload: unknown = {};
if (data.trim()) payload = JSON.parse(data);
await api.resumeExecution(id, { awakeable_id: awakeable, data: payload });
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "resume failed");
} finally {
setResuming(false);
}
}
if (!id) {
return (
<div className="text-sm text-muted-foreground">
Missing <code>id</code> query param.
</div>
);
}
const s = (status?.status as string) || "unknown";
const terminal = ["success", "completed", "failed", "error"].includes(s.toLowerCase());
return (
<div className="space-y-6">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPolling((p) => !p)}
>
{polling ? "Stop polling" : "Resume polling"}
</Button>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw />
Refresh
</Button>
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-semibold tracking-tight">Execution</h1>
<Badge variant={statusVariant(s)}>{s}</Badge>
{!terminal && polling && (
<span className="text-xs text-muted-foreground">polling every 2s</span>
)}
</div>
<p className="mt-1 font-mono text-xs text-muted-foreground">{id}</p>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Live snapshot from the orchestrator</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted/30 p-4 text-xs">
{status ? JSON.stringify(status, null, 2) : "Loading…"}
</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Resume</CardTitle>
<CardDescription>
Resolve a Restate awakeable to continue a paused workflow.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="awakeable">Awakeable ID</Label>
<Input
id="awakeable"
value={awakeable}
onChange={(e) => setAwakeable(e.target.value)}
placeholder="awk_…"
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="data">Resolution data (JSON)</Label>
<Textarea
id="data"
rows={6}
value={data}
onChange={(e) => setData(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<Button
className="w-full"
onClick={onResume}
disabled={resuming || !awakeable}
>
<Send />
{resuming ? "Sending…" : "Resume"}
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
const sample = `{
"name": "Hello",
"nodes": [
{
"id": "1",
"name": "When clicked",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"parameters": {}
},
{
"id": "2",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [200, 0],
"parameters": { "values": { "string": [{ "name": "msg", "value": "hello" }] } }
}
],
"connections": {
"When clicked": { "main": [[{ "node": "Set", "type": "main", "index": 0 }]] }
}
}`;
export default function NewFlowPage() {
const router = useRouter();
const [name, setName] = useState("");
const [definition, setDefinition] = useState(sample);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function onSave() {
setSaving(true);
setError(null);
try {
let parsed: unknown;
try {
parsed = JSON.parse(definition);
} catch (e) {
throw new Error(`Definition must be valid JSON: ${(e as Error).message}`);
}
const { id } = await api.createFlow({ name, definition: parsed });
router.push(`/flows/view/?id=${encodeURIComponent(id)}`);
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
} finally {
setSaving(false);
}
}
return (
<div className="mx-auto w-full max-w-3xl space-y-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">New flow</h1>
<p className="mt-1 text-sm text-muted-foreground">
Paste an n8n workflow export, or start from the sample below.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Definition</CardTitle>
<CardDescription>
n8n-format JSON. Drafts are validated leniently; strict validation runs on
execute.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My flow (optional — uses workflow.name if blank)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="def">Workflow JSON</Label>
<Textarea
id="def"
rows={20}
value={definition}
onChange={(e) => setDefinition(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Link href="/">
<Button variant="ghost">Cancel</Button>
</Link>
<Button onClick={onSave} disabled={saving}>
<Save />
{saving ? "Saving…" : "Save flow"}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Play, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api, type StoredFlow } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export default function FlowDetailPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
<FlowDetail />
</Suspense>
);
}
function FlowDetail() {
const router = useRouter();
const params = useSearchParams();
const id = params.get("id") ?? "";
const [flow, setFlow] = useState<StoredFlow | null>(null);
const [name, setName] = useState("");
const [definition, setDefinition] = useState("");
const [input, setInput] = useState("[{}]");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [lastExecId, setLastExecId] = useState<string | null>(null);
async function load() {
if (!id) return;
try {
const f = await api.getFlow(id);
setFlow(f);
setName(f.name);
setDefinition(JSON.stringify(f.definition, null, 2));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
async function onSave() {
setSaving(true);
setError(null);
try {
const parsed = JSON.parse(definition);
await api.updateFlow(id, { name, definition: parsed });
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
} finally {
setSaving(false);
}
}
async function onRun() {
setRunning(true);
setError(null);
try {
let parsedInput: unknown[] | undefined;
if (input.trim()) {
const v = JSON.parse(input);
if (!Array.isArray(v)) throw new Error("Input must be a JSON array");
parsedInput = v;
}
const res = await api.executeWorkflow({ workflow_id: id, input: parsedInput });
setLastExecId(res.execution_id);
router.push(`/executions/view/?id=${encodeURIComponent(res.execution_id)}`);
} catch (e) {
setError(e instanceof Error ? e.message : "run failed");
} finally {
setRunning(false);
}
}
async function onDelete() {
if (!confirm("Delete this flow? This cannot be undone.")) return;
try {
await api.deleteFlow(id);
router.push("/");
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
}
}
if (!id) {
return (
<div className="text-sm text-muted-foreground">
Missing <code>id</code> query param.
</div>
);
}
return (
<div className="space-y-6">
{flow && (
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span className="font-mono">{flow.id}</span>
<span>·</span>
<span>updated {formatDate(flow.updatedAt)}</span>
<Badge variant="secondary">{flow.nodeCount} nodes</Badge>
</div>
)}
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{name || "Untitled flow"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Edit, save, and run this workflow against the orchestrator.
</p>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Definition</CardTitle>
<CardDescription>n8n-format JSON</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="def">Workflow JSON</Label>
<Textarea
id="def"
rows={24}
value={definition}
onChange={(e) => setDefinition(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onDelete}>
<Trash2 />
Delete
</Button>
<Button onClick={onSave} disabled={saving}>
<Save />
{saving ? "Saving…" : "Save"}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Run</CardTitle>
<CardDescription>Submit to the orchestrator</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="input">Trigger input (JSON array)</Label>
<Textarea
id="input"
rows={8}
value={input}
onChange={(e) => setInput(e.target.value)}
spellCheck={false}
className="text-xs"
/>
<p className="text-xs text-muted-foreground">
e.g. <code className="font-mono">[{`{"foo":"bar"}`}]</code>
</p>
</div>
<Button className="w-full" onClick={onRun} disabled={running}>
<Play />
{running ? "Submitting…" : "Run flow"}
</Button>
{lastExecId && (
<div className="rounded-md border bg-muted/30 p-3 text-xs">
<div className="font-medium">Last execution</div>
<Link
href={`/executions/view/?id=${encodeURIComponent(lastExecId)}`}
className="font-mono text-primary hover:underline"
>
{lastExecId}
</Link>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.6rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
html,
body {
@apply bg-background text-foreground antialiased;
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Breadcrumbs } from "@/components/breadcrumbs";
export const metadata: Metadata = {
title: "flow",
description: "Durable n8n-compatible workflow engine",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased">
<SidebarProvider
style={{ "--sidebar-width": "17rem" } as React.CSSProperties}
>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 border-b bg-background/80 px-4 backdrop-blur">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumbs />
</header>
<div className="flex flex-1 flex-col gap-4 p-6">{children}</div>
</SidebarInset>
</SidebarProvider>
</body>
</html>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Plus, RefreshCw, Trash2, Activity } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { api, type FlowSummary } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export default function DashboardPage() {
const [flows, setFlows] = useState<FlowSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [health, setHealth] = useState<"ok" | "down" | "checking">("checking");
async function load() {
try {
const list = await api.listFlows();
list.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
setFlows(list);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "failed to load");
}
}
useEffect(() => {
load();
api.health()
.then(() => setHealth("ok"))
.catch(() => setHealth("down"));
}, []);
async function onDelete(id: string) {
if (!confirm("Delete this flow?")) return;
try {
await api.deleteFlow(id);
await load();
} catch (e) {
alert(e instanceof Error ? e.message : "delete failed");
}
}
return (
<div className="space-y-8">
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Flows</h1>
<p className="mt-1 text-sm text-muted-foreground">
Durable, n8n-compatible workflows. Paste exported JSON to import.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={
health === "ok"
? "h-2 w-2 rounded-full bg-emerald-500"
: health === "down"
? "h-2 w-2 rounded-full bg-rose-500"
: "h-2 w-2 rounded-full bg-amber-500"
}
/>
<span>API {health}</span>
</div>
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw />
Refresh
</Button>
<Link href="/flows/new">
<Button size="sm">
<Plus />
New flow
</Button>
</Link>
</div>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
)}
{flows === null ? (
<SkeletonGrid />
) : flows.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{flows.map((f) => (
<Card key={f.id} className="group transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{f.name || "Untitled flow"}</CardTitle>
<CardDescription className="mt-1 truncate font-mono text-[11px]">
{f.id}
</CardDescription>
</div>
<Badge variant="secondary">{f.status || "draft"}</Badge>
</div>
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>
<span className="font-medium text-foreground">{f.nodeCount}</span> nodes
</span>
<span>·</span>
<span>{formatDate(f.updatedAt)}</span>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Link href={`/flows/view/?id=${encodeURIComponent(f.id)}`}>
<Button size="sm" variant="ghost">
<Activity />
Open
</Button>
</Link>
<Button
size="icon"
variant="ghost"
onClick={() => onDelete(f.id)}
aria-label="Delete flow"
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
function SkeletonGrid() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-1/3 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
);
}
function EmptyState() {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<div className="rounded-full bg-muted p-3">
<Plus className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">No flows yet</p>
<p className="text-sm text-muted-foreground">
Import an n8n workflow JSON to get started.
</p>
</div>
<Link href="/flows/new">
<Button size="sm">Create your first flow</Button>
</Link>
</CardContent>
</Card>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Workflow,
LayoutGrid,
PlusCircle,
Activity,
BookOpen,
ExternalLink,
Github,
} from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
} from "@/components/ui/sidebar";
type NavItem = {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
match?: (pathname: string) => boolean;
};
const primary: NavItem[] = [
{
title: "Flows",
href: "/",
icon: LayoutGrid,
match: (p) => p === "/" || p.startsWith("/flows/view"),
},
{
title: "New flow",
href: "/flows/new",
icon: PlusCircle,
match: (p) => p.startsWith("/flows/new"),
},
{
title: "Executions",
href: "/executions/view",
icon: Activity,
match: (p) => p.startsWith("/executions"),
},
];
const docs = [
{
title: "n8n compatibility",
href: "https://github.com/lyzrai/flow#n8n-compatible",
external: true,
},
{
title: "Durability model",
href: "https://github.com/lyzrai/flow#durability",
external: true,
},
{
title: "Embed as Go library",
href: "https://github.com/lyzrai/flow#embedding",
external: true,
},
];
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname() || "/";
return (
<Sidebar variant="floating" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Workflow className="size-4" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">flow</span>
<span className="text-xs text-muted-foreground">
pre-v0.1 · durable workflows
</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarMenu className="gap-1">
{primary.map((item) => {
const Icon = item.icon;
const active = item.match
? item.match(pathname)
: pathname === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={active}>
<Link href={item.href} className="font-medium">
<Icon className="size-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupLabel>
<BookOpen className="mr-1 size-3.5" />
Reference
</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton className="font-medium" disabled>
Documentation
</SidebarMenuButton>
<SidebarMenuSub className="ml-0 border-l-0 px-1.5">
{docs.map((d) => (
<SidebarMenuSubItem key={d.href}>
<SidebarMenuSubButton asChild>
<a href={d.href} target="_blank" rel="noreferrer">
<span className="truncate">{d.title}</span>
{d.external && (
<ExternalLink className="ml-auto size-3 opacity-60" />
)}
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a
href="https://github.com/lyzrai/flow"
target="_blank"
rel="noreferrer"
>
<Github className="size-4" />
<span>GitHub</span>
<ExternalLink className="ml-auto size-3 opacity-60" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
export function Breadcrumbs() {
return (
<Suspense fallback={<div className="h-4" />}>
<BreadcrumbsInner />
</Suspense>
);
}
function BreadcrumbsInner() {
const pathname = usePathname() || "/";
const params = useSearchParams();
const crumbs = derive(pathname, params);
return (
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((c, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={`${c.label}-${i}`} className="contents">
<BreadcrumbItem className={i === 0 ? "hidden md:block" : undefined}>
{isLast || !c.href ? (
<BreadcrumbPage>{c.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={c.href}>{c.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && (
<BreadcrumbSeparator
className={i === 0 ? "hidden md:block" : undefined}
/>
)}
</span>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}
function derive(
pathname: string,
params: URLSearchParams | null
): { label: string; href?: string }[] {
const id = params?.get("id");
if (pathname === "/" || pathname === "") {
return [{ label: "flow", href: "/" }, { label: "Flows" }];
}
if (pathname.startsWith("/flows/new")) {
return [
{ label: "flow", href: "/" },
{ label: "Flows", href: "/" },
{ label: "New" },
];
}
if (pathname.startsWith("/flows/view")) {
return [
{ label: "flow", href: "/" },
{ label: "Flows", href: "/" },
{ label: id ? `Flow ${id.slice(0, 8)}` : "Flow" },
];
}
if (pathname.startsWith("/executions/view")) {
return [
{ label: "flow", href: "/" },
{ label: "Executions" },
{ label: id ? `${id.slice(0, 12)}` : "Run" },
];
}
return [{ label: "flow", href: "/" }];
}
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-1 focus:ring-ring",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
warning:
"border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-400",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+140
View File
@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+773
View File
@@ -0,0 +1,773 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 font-mono",
className
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+49
View File
@@ -0,0 +1,49 @@
// Package web ships the embedded SPA bundle that the flow HTTP server serves.
//
// The Next.js app under this directory builds a static export into ./out
// (Next's default for `output: "export"`). At Go build time we embed the
// contents of ./out as an io/fs.FS via Dist().
//
// To compile this package the out/ directory must exist and contain at least
// one file. Run `cd web && npm install && npm run build` before `go build`.
// A placeholder is committed so a fresh checkout compiles without requiring
// the npm build first; running the npm build populates the directory.
package web
import (
"embed"
"io/fs"
)
//go:embed all:out
var distFS embed.FS
// Dist returns the embedded SPA bundle rooted at the out/ directory.
// Returns nil if the bundle is empty (unbuilt) — the API server then renders
// a small "frontend not built" notice.
func Dist() fs.FS {
sub, err := fs.Sub(distFS, "out")
if err != nil {
return nil
}
// Treat an empty bundle (only the placeholder marker) as nil so the
// server falls back to the no-bundle notice instead of serving a stub.
if isEmpty(sub) {
return nil
}
return sub
}
func isEmpty(fsys fs.FS) bool {
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
return true
}
for _, e := range entries {
if e.Name() == ".gitkeep" {
continue
}
return false
}
return true
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+83
View File
@@ -0,0 +1,83 @@
// API client for the Go flow server. Static export → calls go to same origin.
export type FlowSummary = {
id: string;
name: string;
description?: string;
updatedAt: string;
nodeCount: number;
status?: string;
};
export type StoredFlow = {
id: string;
name: string;
definition: unknown;
updatedAt: string;
nodeCount: number;
};
export type ExecutionStatus = {
execution_id?: string;
status?: string;
[k: string]: unknown;
};
const base = ""; // same-origin
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try {
const body = await res.json();
if (body?.error) msg = body.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
health: () => fetch(`${base}/api/health`).then(handle<{ status: string }>),
listFlows: () => fetch(`${base}/api/workflows`).then(handle<FlowSummary[]>),
getFlow: (id: string) => fetch(`${base}/api/workflows/${id}`).then(handle<StoredFlow>),
createFlow: (body: { name: string; definition: unknown }) =>
fetch(`${base}/api/workflows`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<{ id: string }>),
updateFlow: (id: string, body: { name?: string; definition?: unknown }) =>
fetch(`${base}/api/workflows/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<void>),
deleteFlow: (id: string) =>
fetch(`${base}/api/workflows/${id}`, { method: "DELETE" }).then(handle<void>),
executeWorkflow: (body: { workflow_id?: string; workflow?: unknown; input?: unknown[] }) =>
fetch(`${base}/api/workflows/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<{ execution_id: string; status: string }>),
getExecution: (id: string) =>
fetch(`${base}/api/executions/${id}`).then(handle<ExecutionStatus>),
resumeExecution: (id: string, body: { awakeable_id: string; data?: unknown }) =>
fetch(`${base}/api/executions/${id}/resume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(handle<{ message: string }>),
};
+12
View File
@@ -0,0 +1,12 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(value: string | Date) {
const d = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString();
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
// Static export writes to ./out (Go embed reads from there).
// Leave distDir at the default ".next" so build artifacts and the
// export target don't collide.
images: { unoptimized: true },
trailingSlash: true,
reactStrictMode: true,
};
export default nextConfig;
+2897
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "flow-web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "15.1.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+93
View File
@@ -0,0 +1,93 @@
import type { Config } from "tailwindcss";
import animate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: '1.5rem',
screens: {
'2xl': '1320px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [animate],
};
export default config;
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "dist"]
}