tiny-trigger / frontend /src /modules /rules /RuleStudioPanel.tsx
Javier Montalvo
Updated example
9b858f8
Raw History Blame
8.14 kB
import { useState } from "react"
import {
CheckCircle2,
CircleSlash,
Loader2,
Save,
ShieldCheck,
Sparkles,
Trash2,
Wand2,
} from "lucide-react"
import { useDashboard } from "@/lib/dashboard"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
export function RuleStudioPanel() {
const {
rulesText,
setRulesText,
validate,
validation,
save,
setRuleEnabled,
deleteRule,
compiler,
compile,
compiling,
} = useDashboard()
const [instruction, setInstruction] = useState(
"When a cat appears in the image, notify me.",
)
const [armedDelete, setArmedDelete] = useState<string | null>(null)
return (
<Tabs defaultValue="compose">
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="compose">
<Sparkles className="size-3.5" />
Compose
</TabsTrigger>
<TabsTrigger value="source">Source</TabsTrigger>
<TabsTrigger value="rules">
Rules
{validation?.ok && (
<span className="ml-1 font-mono text-[0.625rem] text-muted-foreground">
{validation.rules.length}
</span>
)}
</TabsTrigger>
</TabsList>
{validation &&
(validation.ok ? (
<Badge variant="live">
<CheckCircle2 className="size-3" />
valid
</Badge>
) : (
<Badge variant="danger">
<CircleSlash className="size-3" />
invalid
</Badge>
))}
</div>
{/* Compose: NL → rules */}
<TabsContent value="compose">
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
Describe the automation in plain language. It compiles to validated rules through{" "}
{compiler.provider} — never executable code.
</p>
<Badge variant="default">{compiler.provider}</Badge>
</div>
<Textarea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
className="min-h-24"
placeholder="When a cat is near the feeder, dispense food once every 30 minutes…"
/>
<Button onClick={() => compile(instruction)} disabled={compiling || !instruction.trim()}>
{compiling ? <Loader2 className="size-4 animate-spin" /> : <Wand2 className="size-4" />}
{compiling
? "Compiling…"
: `Compile with ${compiler.provider}`}
</Button>
</div>
</TabsContent>
{/* Source: raw editor */}
<TabsContent value="source">
<div className="space-y-3">
<div className="relative">
<div className="absolute left-0 top-0 z-10 rounded-br-md rounded-tl-md border-b border-r border-border bg-black/40 px-2 py-0.5 font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground">
yaml · json
</div>
<Textarea
value={rulesText}
onChange={(e) => setRulesText(e.target.value)}
spellCheck={false}
className="min-h-64 bg-black/40 pt-7 font-mono text-xs leading-relaxed"
/>
</div>
{validation && !validation.ok && validation.error && (
<pre className="max-h-28 overflow-auto rounded-md border border-destructive/30 bg-destructive/5 p-3 font-mono text-[0.6875rem] text-destructive/90 whitespace-pre-wrap">
{validation.error}
</pre>
)}
<div className="flex gap-2">
<Button variant="secondary" onClick={validate}>
<ShieldCheck className="size-4" />
Validate
</Button>
<Button variant="secondary" onClick={save}>
<Save className="size-4" />
Save
</Button>
</div>
</div>
</TabsContent>
{/* Rules: parsed view */}
<TabsContent value="rules">
{validation?.ok && validation.rules.length > 0 ? (
<div className="space-y-2">
{validation.rules.map((r) => (
<div
key={r.name}
className="flex flex-col gap-3 rounded-lg border border-border bg-black/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 rounded-full",
r.enabled ? "bg-primary" : "bg-muted-foreground/40",
)}
/>
<span className="truncate font-mono text-sm text-foreground">{r.name}</span>
{r.name.startsWith("example-") && <Badge variant="default">example</Badge>}
<Badge variant="default">{r.trigger}</Badge>
</div>
<div className="mt-1 pl-3.5 text-xs text-muted-foreground">
{r.conditions} condition{r.conditions === 1 ? "" : "s"} →{" "}
{r.actions.map((a) => `${a.type}:${a.name}`).join(", ")}
</div>
{r.labels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1 pl-3.5">
{r.labels.map((label) => (
<span
key={label}
className="rounded border border-border bg-black/30 px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground"
>
{label}
</span>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor={`enabled-${r.name}`} className="text-xs text-muted-foreground">
{r.enabled ? "enabled" : "disabled"}
</Label>
<Switch
id={`enabled-${r.name}`}
checked={r.enabled}
onCheckedChange={(checked) => setRuleEnabled(r.name, checked)}
/>
<Button
variant={armedDelete === r.name ? "destructive" : "secondary"}
size="sm"
onClick={() => {
if (armedDelete === r.name) {
void deleteRule(r.name)
setArmedDelete(null)
} else {
setArmedDelete(r.name)
}
}}
>
<Trash2 className="size-4" />
{armedDelete === r.name ? "Confirm" : "Delete"}
</Button>
</div>
</div>
))}
</div>
) : validation?.ok ? (
<div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
<ShieldCheck className="size-7 opacity-40" />
<p className="text-sm">No rules saved.</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
<ShieldCheck className="size-7 opacity-40" />
<p className="text-sm">Validate the source to see parsed rules.</p>
<Button variant="secondary" size="sm" onClick={validate}>
Validate now
</Button>
</div>
)}
</TabsContent>
</Tabs>
)
}