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
+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>
);
}