{selectedId ? "Edit Playbook" : "Create Playbook"}
Select a playbook on the left, then edit here.
import { useEffect, useState, useCallback, useRef } from "react"; import { API_BASE } from "./config"; export function Playbook() { const [playbooks, setPlaybooks] = useState([]); const [selectedId, setSelectedId] = useState(null); const [form, setForm] = useState({ title: "", goal: "", instructions: "", tags: "" }); const [search, setSearch] = useState(""); const [message, setMessage] = useState(""); const dragItem = useRef(null); const dragOverItem = useRef(null); const normalizePlaybooks = (data) => { if (!data) return []; if (Array.isArray(data)) return data; if (Array.isArray(data.playbooks)) return data.playbooks; if (typeof data === "object") return Object.values(data); return []; }; const loadPlaybooks = useCallback(async () => { try { const res = await fetch(`${API_BASE}/playbooks`); if (!res.ok) { setPlaybooks([]); return; } const data = await res.json().catch(() => null); setPlaybooks(normalizePlaybooks(data)); } catch { setPlaybooks([]); } }, []); // eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount; setPlaybooks runs after await, not a synchronous cascading render useEffect(() => { loadPlaybooks(); }, [loadPlaybooks]); const safeString = (v) => (v == null ? "" : String(v)); const filteredPlaybooks = Array.isArray(playbooks) ? playbooks.filter(p => { if (!search.trim()) return true; const q = search.toLowerCase(); return ( safeString(p.title).toLowerCase().includes(q) || safeString(p.goal).toLowerCase().includes(q) || (Array.isArray(p.tags) ? p.tags : []).some(t => safeString(t).toLowerCase().includes(q)) ); }) : []; const tryFetchWithTrailingSlash = async (url, opts) => { let res = await fetch(url, opts); if (res.status === 404 && !url.endsWith("/")) res = await fetch(url + "/", opts); return res; }; const selectPlaybook = async (playbookSummary) => { const id = encodeURIComponent(String(playbookSummary.id)); setMessage(""); setSelectedId(playbookSummary.id); try { const res = await tryFetchWithTrailingSlash(`${API_BASE}/playbooks/${id}`, { method: "GET" }); const full = res.ok ? await res.json() : playbookSummary; setForm({ title: full.title || "", goal: full.goal || "", instructions: full.instructions || "", tags: (full.tags || []).join(", "), tools: (full.tools || []).join(", "), model: full.model || "", }); } catch { setForm({ title: playbookSummary.title || "", goal: playbookSummary.goal || "", instructions: playbookSummary.instructions || "", tags: (playbookSummary.tags || []).join(", "), tools: (playbookSummary.tools || []).join(", "), model: playbookSummary.model || "", }); } }; const resetForm = () => { setSelectedId(null); setForm({ title: "", goal: "", instructions: "", tags: "", tools: "", model: "" }); setMessage(""); }; const savePlaybook = async () => { if (!form.title || !form.goal || !form.instructions) { setMessage("Please fill in title, goal, and instructions."); return; } const payload = { title: form.title, goal: form.goal, instructions: form.instructions, tags: form.tags.split(",").map(t => t.trim()).filter(Boolean), tools: (form.tools || "").split(",").map(t => t.trim()).filter(Boolean), model: (form.model || "").trim(), }; const isUpdate = Boolean(selectedId); const safeId = isUpdate ? encodeURIComponent(String(selectedId)) : null; const baseUrl = isUpdate ? `${API_BASE}/playbooks/${safeId}` : `${API_BASE}/playbooks`; const commonOpts = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }; try { let res; if (isUpdate) { res = await tryFetchWithTrailingSlash(baseUrl, { ...commonOpts, method: "PATCH" }); if (res.status === 404 || res.status === 405) { const getRes = await tryFetchWithTrailingSlash(baseUrl, { method: "GET" }); const existing = getRes.ok ? await getRes.json().catch(() => ({})) : {}; res = await tryFetchWithTrailingSlash(baseUrl, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...existing, ...payload }), }); } } else { res = await tryFetchWithTrailingSlash(baseUrl, { ...commonOpts, method: "POST" }); } if (!res.ok) { const text = await res.text().catch(() => ""); setMessage(`Save failed: ${text || res.statusText}`); return; } setMessage(isUpdate ? "Playbook updated." : "Playbook created."); resetForm(); loadPlaybooks(); } catch (err) { setMessage(`Save error: ${err.message || err}`); } }; const deletePlaybook = async (id) => { if (!window.confirm("Delete this playbook?")) return; const safeId = encodeURIComponent(String(id)); try { const res = await tryFetchWithTrailingSlash(`${API_BASE}/playbooks/${safeId}`, { method: "DELETE" }); if (!res.ok) { const text = await res.text().catch(() => ""); setMessage(`Delete failed: ${text || res.statusText}`); return; } setMessage("Playbook deleted."); if (selectedId === id) resetForm(); loadPlaybooks(); } catch (err) { setMessage(`Delete error: ${err.message || err}`); } }; const onDragStart = (e, id) => { dragItem.current = String(id); e.dataTransfer.effectAllowed = "move"; }; const onDragEnter = (e, id) => { dragOverItem.current = String(id); e.preventDefault(); }; const onDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }; const onDrop = async () => { if (!dragItem.current || dragItem.current === dragOverItem.current) return; const currentOrder = filteredPlaybooks.map(p => String(p.id)); const fromIdx = currentOrder.indexOf(dragItem.current); const toIdx = currentOrder.indexOf(dragOverItem.current); if (fromIdx === -1 || toIdx === -1) return; // Guard: only a playbook with "main" in its title can occupy the first slot if (toIdx === 0 && fromIdx !== 0) { const dragged = filteredPlaybooks.find(p => String(p.id) === dragItem.current); if (!dragged || !dragged.title.toLowerCase().includes("main")) { setMessage("Only a playbook with 'main' in its title can be placed first."); dragItem.current = null; dragOverItem.current = null; return; } } const newFilteredOrder = [...currentOrder]; newFilteredOrder.splice(fromIdx, 1); newFilteredOrder.splice(toIdx, 0, dragItem.current); // Optimistic UI update — reorder within filtered set, keep unfiltered playbooks intact setPlaybooks(prev => { const filteredSet = new Set(currentOrder); const filteredMap = Object.fromEntries(prev.filter(p => filteredSet.has(String(p.id))).map(p => [String(p.id), p])); const reorderedFiltered = newFilteredOrder.map(id => filteredMap[id]).filter(Boolean); // Merge: place unfiltered items back in their original relative positions const result = []; let fi = 0; for (const p of prev) { if (filteredSet.has(String(p.id))) { if (fi < reorderedFiltered.length) result.push(reorderedFiltered[fi++]); } else { result.push(p); } } return result; }); dragItem.current = null; dragOverItem.current = null; // Send the full playbook order to the backend (filtered reorder merged with all IDs) try { await fetch(`${API_BASE}/playbooks/reorder`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: newFilteredOrder }), }); } catch (err) { console.error("Failed to persist order:", err); loadPlaybooks(); } }; return (
Select a playbook on the left, then edit here.