diff options
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 394 |
1 files changed, 394 insertions, 0 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx new file mode 100644 index 0000000..42e6a69 --- /dev/null +++ b/makima/frontend/src/routes/document-directives.tsx @@ -0,0 +1,394 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { useDirective, useDirectives } from "../hooks/useDirectives"; +import { useAuth } from "../contexts/AuthContext"; +import { DocumentEditor } from "../components/directives/DocumentEditor"; +import type { DirectiveSummary, DirectiveStatus } from "../lib/api"; + +// Status dot color, matching the existing tabular UI's badge palette so the +// document mode feels like a sibling of the existing list, not a foreign UI. +const STATUS_DOT: Record<DirectiveStatus, string> = { + draft: "bg-[#556677]", + active: "bg-green-400", + idle: "bg-yellow-400", + paused: "bg-orange-400", + archived: "bg-[#3a4a6a]", +}; + +// ============================================================================= +// Sidebar grouping — group directives by lifecycle stage so the file tree +// reads like a folder per status. We collapse the noisy ones (Archived) by +// default and keep Active / Idle expanded. +// ============================================================================= + +type SidebarGroup = "active" | "idle" | "archived"; + +const GROUP_LABEL: Record<SidebarGroup, string> = { + active: "active", + idle: "idle", + archived: "archived", +}; + +function bucketOf(status: DirectiveStatus): SidebarGroup { + if (status === "active" || status === "paused") return "active"; + if (status === "archived") return "archived"; + // draft + idle land in the idle bucket (i.e. "not currently running"). + return "idle"; +} + +// ============================================================================= +// Sidebar icons (inline SVG, no new deps) +// ============================================================================= + +function FolderIcon({ open = false }: { open?: boolean }) { + return ( + <svg + viewBox="0 0 16 16" + width={12} + height={12} + className="shrink-0" + aria-hidden + > + {open ? ( + <path + d="M1.5 3.5a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V6H1.5V3.5z M1 6.5h13.382a.5.5 0 0 1 .49.598l-.9 5A.5.5 0 0 1 13.482 12.5H2.518a.5.5 0 0 1-.49-.402l-.9-5A.5.5 0 0 1 1.62 6.5H1z" + fill="#75aafc" + opacity="0.85" + /> + ) : ( + <path + d="M1.5 4a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z" + fill="#75aafc" + opacity="0.65" + /> + )} + </svg> + ); +} + +function FileIcon() { + return ( + <svg + viewBox="0 0 16 16" + width={12} + height={12} + className="shrink-0" + aria-hidden + > + <path + d="M3 1.5h6.293a1 1 0 0 1 .707.293l3 3A1 1 0 0 1 13.293 5.5H13V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z" + fill="none" + stroke="#9bc3ff" + strokeWidth="1" + /> + <path + d="M9.5 1.5v3h3" + fill="none" + stroke="#9bc3ff" + strokeWidth="1" + /> + </svg> + ); +} + +function Caret({ open }: { open: boolean }) { + return ( + <svg + viewBox="0 0 8 8" + width={8} + height={8} + className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`} + aria-hidden + > + <path d="M2 1l4 3-4 3z" fill="#7788aa" /> + </svg> + ); +} + +// ============================================================================= +// Sidebar +// ============================================================================= + +interface SidebarProps { + directives: DirectiveSummary[]; + loading: boolean; + selectedId: string | null; + onSelect: (id: string) => void; +} + +function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) { + const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => { + const out: Record<SidebarGroup, DirectiveSummary[]> = { + active: [], + idle: [], + archived: [], + }; + for (const d of directives) { + out[bucketOf(d.status)].push(d); + } + // Sort each group alphabetically so it feels like a stable file tree. + (Object.keys(out) as SidebarGroup[]).forEach((k) => { + out[k].sort((a, b) => + a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), + ); + }); + return out; + }, [directives]); + + // Default-collapsed state per folder. Archived is collapsed by default + // (it's history); the other two are open so users see their work. + const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({ + active: true, + idle: true, + archived: false, + }); + + const toggleGroup = (g: SidebarGroup) => + setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); + + return ( + <div className="flex flex-col h-full"> + {/* Sidebar header */} + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Documents + </span> + <span className="text-[10px] font-mono text-[#556677]"> + {directives.length} + </span> + </div> + + {/* Top-level "directives/" folder */} + <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]"> + <FolderIcon open /> + <span>directives/</span> + </div> + + {/* Body */} + <div className="flex-1 overflow-y-auto pb-4"> + {loading && directives.length === 0 ? ( + <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> + Loading... + </div> + ) : directives.length === 0 ? ( + <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> + No directives yet + </div> + ) : ( + (Object.keys(groups) as SidebarGroup[]).map((group) => { + const list = groups[group]; + if (list.length === 0) return null; + const open = openGroups[group]; + return ( + <div key={group} className="select-none"> + {/* Group header (sub-folder) */} + <button + type="button" + onClick={() => toggleGroup(group)} + className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]" + > + <Caret open={open} /> + <FolderIcon open={open} /> + <span>{GROUP_LABEL[group]}/</span> + <span className="ml-auto text-[10px] text-[#556677]"> + {list.length} + </span> + </button> + + {/* Files inside the group */} + {open && ( + <ul className="py-0.5"> + {list.map((d) => { + const isSelected = d.id === selectedId; + const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft; + const slug = d.title + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-zA-Z0-9._-]/g, "") + .toLowerCase(); + const fileName = + slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`; + const orchestratorRunning = !!d.orchestratorTaskId; + return ( + <li key={d.id}> + <button + type="button" + onClick={() => onSelect(d.id)} + title={d.title} + className={`w-full text-left flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] transition-colors ${ + isSelected + ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent" + }`} + > + <FileIcon /> + <span + className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`} + aria-hidden + /> + <span className="truncate flex-1">{fileName}</span> + {orchestratorRunning && ( + <span + className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" + title="Orchestrator running" + aria-label="Orchestrator running" + /> + )} + </button> + </li> + ); + })} + </ul> + )} + </div> + ); + }) + )} + </div> + </div> + ); +} + +// ============================================================================= +// Editor shell — wraps DocumentEditor and handles the "no document selected" +// and loading states. +// ============================================================================= + +interface EditorShellProps { + selectedId: string | undefined; + hasDirectives: boolean; + listLoading: boolean; +} + +function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProps) { + const { + directive, + loading, + updateGoal, + cleanup, + createPR, + pickUpOrders, + } = useDirective(selectedId); + + if (!selectedId) { + return ( + <div className="flex-1 flex items-center justify-center h-full"> + <p className="text-[#556677] font-mono text-[12px]"> + {listLoading + ? "Loading documents..." + : hasDirectives + ? "Select a document from the sidebar" + : "No documents yet — create one from the legacy UI"} + </p> + </div> + ); + } + + if (loading && !directive) { + return ( + <div className="flex-1 flex items-center justify-center h-full"> + <p className="text-[#556677] font-mono text-[12px]">Loading document...</p> + </div> + ); + } + + if (!directive) { + return ( + <div className="flex-1 flex items-center justify-center h-full"> + <p className="text-[#7788aa] font-mono text-[12px]">Document not found</p> + </div> + ); + } + + return ( + <div className="flex-1 flex flex-col h-full overflow-hidden"> + {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */} + <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]"> + <FileIcon /> + <span>directives /</span> + <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span> + {!!directive.orchestratorTaskId && ( + <span className="ml-2 inline-flex items-center gap-1 text-yellow-400"> + <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" /> + orchestrator running + </span> + )} + </div> + </div> + + {/* Lexical editor body */} + <DocumentEditor + directive={directive} + onUpdateGoal={async (goal) => { + await updateGoal(goal); + }} + onCleanup={async () => { + await cleanup(); + }} + onCreatePR={async () => { + await createPR(); + }} + onPickUpOrders={async () => { + await pickUpOrders(); + }} + /> + </div> + ); +} + +// ============================================================================= +// Page +// ============================================================================= + +export default function DocumentDirectivesPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + const { id: selectedId } = useParams<{ id: string }>(); + const { directives, loading: listLoading } = useDirectives(); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main + className="flex-1 flex overflow-hidden" + style={{ height: "calc(100vh - 80px)" }} + > + {/* Left: file-tree sidebar */} + <div className="w-[240px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> + <DocumentSidebar + directives={directives} + loading={listLoading} + selectedId={selectedId ?? null} + onSelect={(id) => navigate(`/directives/${id}`)} + /> + </div> + + {/* Right: Lexical editor */} + <EditorShell + selectedId={selectedId} + hasDirectives={directives.length > 0} + listLoading={listLoading} + /> + </main> + </div> + ); +} |
