diff options
Diffstat (limited to 'makima/frontend/src/routes')
| -rw-r--r-- | makima/frontend/src/routes/chains.tsx | 283 |
1 files changed, 283 insertions, 0 deletions
diff --git a/makima/frontend/src/routes/chains.tsx b/makima/frontend/src/routes/chains.tsx new file mode 100644 index 0000000..f01d5a6 --- /dev/null +++ b/makima/frontend/src/routes/chains.tsx @@ -0,0 +1,283 @@ +import { useState, useCallback, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { ChainList } from "../components/chains/ChainList"; +import { ChainEditor } from "../components/chains/ChainEditor"; +import { useChains } from "../hooks/useChains"; +import { useAuth } from "../contexts/AuthContext"; +import type { + ChainSummary, + ChainWithContracts, + ChainGraphResponse, + CreateChainRequest, +} from "../lib/api"; + +export default function ChainsPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + 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> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <ChainsPageContent />; +} + +function ChainsPageContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + chains, + loading, + error, + createNewChain, + archiveExistingChain, + getChainById, + getGraph, + } = useChains(); + + const [chainDetail, setChainDetail] = useState<ChainWithContracts | null>(null); + const [chainGraph, setChainGraph] = useState<ChainGraphResponse | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + + // Load chain detail when ID changes + useEffect(() => { + if (id) { + setDetailLoading(true); + Promise.all([getChainById(id), getGraph(id)]).then(([chain, graph]) => { + setChainDetail(chain); + setChainGraph(graph); + setDetailLoading(false); + }); + } else { + setChainDetail(null); + setChainGraph(null); + } + }, [id, getChainById, getGraph]); + + const handleSelect = useCallback( + (chainId: string) => { + navigate(`/chains/${chainId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + navigate("/chains"); + }, [navigate]); + + const handleCreate = useCallback(() => { + setIsCreating(true); + }, []); + + const handleCreateSubmit = useCallback( + async (name: string, description: string) => { + const data: CreateChainRequest = { + name: name.trim(), + description: description.trim() || undefined, + }; + + try { + const result = await createNewChain(data); + if (result) { + setIsCreating(false); + navigate(`/chains/${result.chain.id}`); + } + } catch (err) { + console.error("Failed to create chain:", err); + } + }, + [createNewChain, navigate] + ); + + const handleCreateCancel = useCallback(() => { + setIsCreating(false); + }, []); + + const handleArchive = useCallback( + async (chain: ChainSummary) => { + if (confirm(`Are you sure you want to archive "${chain.name}"?`)) { + const success = await archiveExistingChain(chain.id); + if (success && chain.id === id) { + navigate("/chains"); + } + } + }, + [archiveExistingChain, id, navigate] + ); + + const handleRefresh = useCallback(async () => { + if (id) { + const [chain, graph] = await Promise.all([getChainById(id), getGraph(id)]); + setChainDetail(chain); + setChainGraph(graph); + } + }, [id, getChainById, getGraph]); + + const handleContractClick = useCallback( + (contractId: string) => { + navigate(`/contracts/${contractId}`); + }, + [navigate] + ); + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Create chain modal */} + {isCreating && ( + <CreateChainModal + onSubmit={handleCreateSubmit} + onCancel={handleCreateCancel} + /> + )} + + <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> + {/* Chain list */} + <ChainList + chains={chains} + loading={loading} + onSelect={handleSelect} + onCreate={handleCreate} + selectedId={id} + onArchive={handleArchive} + /> + + {/* Chain detail/editor or empty state */} + {chainDetail ? ( + <ChainEditor + chain={chainDetail} + graph={chainGraph} + loading={detailLoading} + onBack={handleBack} + onRefresh={handleRefresh} + onContractClick={handleContractClick} + /> + ) : ( + <div className="panel h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + Select a chain or create a new one + </p> + <button + onClick={handleCreate} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New Chain + </button> + </div> + </div> + )} + </div> + </main> + </div> + ); +} + +interface CreateChainModalProps { + onSubmit: (name: string, description: string) => void; + onCancel: () => void; +} + +function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const handleSubmit = () => { + if (name.trim()) { + onSubmit(name.trim(), description.trim()); + } + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Chain + </h3> + + <div className="space-y-4"> + {/* Chain name */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Chain Name + </label> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="e.g., Feature Implementation" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Description */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Description (optional) + </label> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="Describe what this chain accomplishes..." + rows={3} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + /> + </div> + + <p className="font-mono text-xs text-[#8b949e]"> + A chain links multiple contracts together in a directed acyclic graph (DAG). + Contracts can depend on each other, and dependent contracts start automatically + when their dependencies complete. + </p> + + {/* Actions */} + <div className="flex gap-2 justify-end pt-2"> + <button + onClick={onCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleSubmit} + disabled={!name.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + ); +} |
