summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx394
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>
+ );
+}