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