HomePilot / frontend /src /ui /interactive /RulesPanel.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
14.4 kB
/**
* RulesPanel — personalization rules CRUD.
*
* Backend already enforces DSL validation (see
* personalize/rules.py::validate_rule). We mirror the allowed-key
* list here so the form can surface local hints, but the final
* word on validity belongs to the server — on 400 we render the
* server's `data.problems` array inline.
*
* Rules are compact JSON blobs. The form exposes:
* - name (free text)
* - priority (int; lower = higher priority)
* - enabled (toggle)
* - condition + action (JSON textareas with live parse errors)
*
* Advanced users can edit the JSON directly; typical users see
* a hint listing the allowed keys so they don't have to read the
* backend source.
*/
import React, { useCallback, useMemo, useState } from "react";
import { Sparkles, Trash2 } from "lucide-react";
import type { InteractiveApi } from "./api";
import { InteractiveApiError } from "./types";
import type { RuleItem } from "./types";
import {
DangerButton,
EmptyState,
ErrorBanner,
Modal,
Panel,
PrimaryButton,
SecondaryButton,
SkeletonRow,
useAsyncResource,
useToast,
} from "./ui";
export interface RulesPanelProps {
api: InteractiveApi;
projectId: string;
}
const ALLOWED_CONDITION_KEYS = [
"role", "level", "language", "country",
"has_tag", "mood", "min_affinity", "max_affinity", "metric",
];
const ALLOWED_ACTION_KEYS = [
"route_to_node", "prefer_tone", "bump_affinity",
];
export function RulesPanel({ api, projectId }: RulesPanelProps) {
const toast = useToast();
const resource = useAsyncResource<RuleItem[]>(
(signal) => api.listRules(projectId, signal),
[api, projectId],
);
const [createOpen, setCreateOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<RuleItem | null>(null);
const items = useMemo(() => resource.data || [], [resource.data]);
const onCreated = useCallback((created: RuleItem) => {
resource.setData((prev) => {
const next = [...(prev || []), created];
next.sort((a, b) => a.priority - b.priority);
return next;
});
}, [resource]);
const onDelete = useCallback(async () => {
const target = confirmDelete;
if (!target) return;
const snapshot = items;
resource.setData((prev) => (prev || []).filter((r) => r.id !== target.id));
setConfirmDelete(null);
try {
await api.deleteRule(target.id);
toast.toast({ variant: "success", title: "Rule deleted" });
} catch (err) {
const e = err as InteractiveApiError;
resource.setData(snapshot);
toast.toast({
variant: "error",
title: "Couldn't delete rule",
message: e.message || "The rule has been restored.",
});
}
}, [api, confirmDelete, items, resource, toast]);
return (
<Panel
title="Personalization rules"
subtitle="Declarative rules that bias the runtime router. Lower priority wins."
actions={
<PrimaryButton
onClick={() => setCreateOpen(true)}
size="sm"
icon={<Sparkles className="w-4 h-4" aria-hidden />}
>
New rule
</PrimaryButton>
}
>
{resource.error ? (
<ErrorBanner
title="Couldn't load rules"
message={resource.error}
onRetry={resource.reload}
/>
) : resource.loading && !resource.data ? (
<div className="flex flex-col gap-2" aria-busy="true">
{Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} />)}
</div>
) : items.length === 0 ? (
<EmptyState
icon={<Sparkles className="w-12 h-12" aria-hidden />}
title="No personalization rules"
description="Rules let you route viewers based on mood, affinity, or audience facets. Great for adaptive paths without coding."
action={
<PrimaryButton onClick={() => setCreateOpen(true)} icon={<Sparkles className="w-4 h-4" aria-hidden />}>
Add your first rule
</PrimaryButton>
}
/>
) : (
<ul className="flex flex-col gap-2">
{items.map((r) => (
<RuleRow key={r.id} rule={r} onDelete={() => setConfirmDelete(r)} />
))}
</ul>
)}
<NewRuleModal
open={createOpen}
onClose={() => setCreateOpen(false)}
api={api}
projectId={projectId}
onCreated={(r) => {
onCreated(r);
setCreateOpen(false);
toast.toast({ variant: "success", title: "Rule created", message: r.name });
}}
/>
<Modal
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
title="Delete rule?"
footer={
<>
<SecondaryButton onClick={() => setConfirmDelete(null)}>Cancel</SecondaryButton>
<DangerButton onClick={onDelete} icon={<Trash2 className="w-4 h-4" aria-hidden />}>
Delete
</DangerButton>
</>
}
>
<p className="text-sm text-[#cfd8dc]">
This permanently removes <span className="font-medium">{confirmDelete?.name}</span>.
</p>
</Modal>
</Panel>
);
}
// ────────────────────────────────────────────────────────────────
// Rule row
// ────────────────────────────────────────────────────────────────
function RuleRow({ rule, onDelete }: { rule: RuleItem; onDelete: () => void }) {
const condKeys = Object.keys(rule.condition || {});
const actionKeys = Object.keys(rule.action || {});
return (
<li className="bg-[#121212] border border-[#3f3f3f] rounded-md p-3 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[#f1f1f1]">{rule.name}</span>
<span className="text-[10px] text-[#777]">priority {rule.priority}</span>
{!rule.enabled && (
<span className="text-[10px] uppercase tracking-wide text-amber-300 bg-amber-400/10 border border-amber-400/30 rounded px-1.5 py-0.5">
disabled
</span>
)}
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5 text-[11px]">
{condKeys.length > 0 ? condKeys.map((k) => (
<code key={`c:${k}`} className="bg-[#1f1f1f] border border-[#3f3f3f] rounded px-1.5 py-0.5 text-[#cfd8dc]">
when {k}
</code>
)) : <span className="text-[#777]">no conditions</span>}
{actionKeys.map((k) => (
<code key={`a:${k}`} className="bg-[#3ea6ff]/10 border border-[#3ea6ff]/40 rounded px-1.5 py-0.5 text-[#3ea6ff]">
→ {k}
</code>
))}
</div>
</div>
<button
type="button"
onClick={onDelete}
aria-label={`Delete ${rule.name}`}
className="text-[#aaa] hover:text-red-400 p-1 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<Trash2 className="w-4 h-4" aria-hidden />
</button>
</li>
);
}
// ────────────────────────────────────────────────────────────────
// New-rule modal
// ────────────────────────────────────────────────────────────────
function NewRuleModal({
open, onClose, api, projectId, onCreated,
}: {
open: boolean;
onClose: () => void;
api: InteractiveApi;
projectId: string;
onCreated: (r: RuleItem) => void;
}) {
const toast = useToast();
const [name, setName] = useState("");
const [priority, setPriority] = useState(100);
const [enabled, setEnabled] = useState(true);
const [conditionText, setConditionText] = useState('{\n "mood": "flirty"\n}');
const [actionText, setActionText] = useState('{\n "bump_affinity": 0.05\n}');
const [submitting, setSubmitting] = useState(false);
const [serverProblems, setServerProblems] = useState<string[] | null>(null);
const conditionErr = useMemo(() => parseJsonError(conditionText), [conditionText]);
const actionErr = useMemo(() => parseJsonError(actionText), [actionText]);
const reset = useCallback(() => {
setName(""); setPriority(100); setEnabled(true);
setConditionText('{\n "mood": "flirty"\n}');
setActionText('{\n "bump_affinity": 0.05\n}');
setServerProblems(null);
}, []);
const canSubmit = name.trim().length > 0 && !conditionErr && !actionErr && !submitting;
const submit = useCallback(async () => {
if (!canSubmit) return;
setSubmitting(true);
setServerProblems(null);
try {
const condition = JSON.parse(conditionText) as Record<string, unknown>;
const action = JSON.parse(actionText) as Record<string, unknown>;
const created = await api.createRule(projectId, {
name: name.trim(), condition, action,
priority, enabled,
});
reset();
onCreated(created);
} catch (err) {
const e = err as InteractiveApiError;
const problems = Array.isArray(e.data?.problems) ? (e.data.problems as string[]) : null;
if (problems && problems.length > 0) {
setServerProblems(problems);
} else {
toast.toast({
variant: "error",
title: "Couldn't create rule",
message: e.message || "Check the JSON and try again.",
});
}
setSubmitting(false);
}
}, [actionText, api, canSubmit, conditionText, enabled, name, onCreated, priority, projectId, reset, toast]);
return (
<Modal
open={open}
onClose={() => { if (!submitting) { reset(); onClose(); } }}
title="New personalization rule"
widthClass="max-w-2xl"
footer={
<>
<SecondaryButton onClick={() => { reset(); onClose(); }} disabled={submitting}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={submit} disabled={!canSubmit} loading={submitting}>
Create
</PrimaryButton>
</>
}
>
<div className="flex flex-col gap-4">
<Field label="Name" required>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Warm up when the viewer is flirty"
maxLength={80}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Priority" hint="Lower wins ties. Default 100.">
<input
type="number"
min={1} max={1000}
value={priority}
onChange={(e) => setPriority(Math.max(1, parseInt(e.target.value, 10) || 1))}
className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]"
/>
</Field>
<Field label="Enabled">
<label className="inline-flex items-center gap-2 mt-1">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="accent-[#3ea6ff]"
/>
<span className="text-sm text-[#cfd8dc]">Active at runtime</span>
</label>
</Field>
</div>
<Field label="Condition (JSON)" hint={`Allowed keys: ${ALLOWED_CONDITION_KEYS.join(", ")}.`}>
<textarea
rows={5}
value={conditionText}
onChange={(e) => setConditionText(e.target.value)}
className={[
"w-full bg-[#121212] border rounded-md px-3 py-2 text-xs font-mono outline-none",
conditionErr ? "border-red-500/50 focus:border-red-500" : "border-[#3f3f3f] focus:border-[#3ea6ff]",
].join(" ")}
spellCheck={false}
/>
{conditionErr && <p className="text-[11px] text-red-400 mt-1">{conditionErr}</p>}
</Field>
<Field label="Action (JSON)" hint={`Allowed keys: ${ALLOWED_ACTION_KEYS.join(", ")}.`}>
<textarea
rows={4}
value={actionText}
onChange={(e) => setActionText(e.target.value)}
className={[
"w-full bg-[#121212] border rounded-md px-3 py-2 text-xs font-mono outline-none",
actionErr ? "border-red-500/50 focus:border-red-500" : "border-[#3f3f3f] focus:border-[#3ea6ff]",
].join(" ")}
spellCheck={false}
/>
{actionErr && <p className="text-[11px] text-red-400 mt-1">{actionErr}</p>}
</Field>
{serverProblems && serverProblems.length > 0 && (
<div role="alert" className="border border-red-500/40 bg-red-500/5 rounded-md p-3 text-xs text-red-300">
<div className="font-medium mb-1">The server rejected this rule:</div>
<ul className="list-disc pl-5 space-y-0.5">
{serverProblems.map((p, i) => <li key={i}>{p}</li>)}
</ul>
</div>
)}
</div>
</Modal>
);
}
function Field({
label, hint, required, children,
}: {
label: string;
hint?: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-[#cfd8dc]">
{label}
{required && <span className="text-[#3ea6ff] ml-0.5" aria-label="required">*</span>}
</label>
{hint && <p className="text-[11px] text-[#777] -mt-0.5">{hint}</p>}
{children}
</div>
);
}
function parseJsonError(text: string): string | null {
try {
const parsed = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return "Must be a JSON object.";
}
return null;
} catch (e) {
return `Invalid JSON: ${(e as Error).message}`;
}
}