summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/chains.tsx
blob: 9b3330491df2e0ef5e9221b5996ee65c24cea66f (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12











                                                               

                            
                    
                                                      
















































































                                                                                  
                                                                                             


                                                     
                                                                         





                                                  
                                           



































































































                                                                                                                                                            
                                                                                                   


                       

                                          


                                                                          































































                                                                                                      


                              
                                                              




                                                                                         
                                                                                                                           







                                                                                     
                          



















                                                                                                                                                          
                      



                                                                                                                                                                      















































































































































                                                                                                                                                                         
                                                          

                                                                                        






















                                                                                                                                                                                              
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,
  AddChainRepositoryRequest,
  RepositoryHistoryEntry,
} from "../lib/api";
import { getRepositorySuggestions } 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, repositories: AddChainRepositoryRequest[]) => {
      const data: CreateChainRequest = {
        name: name.trim(),
        description: description.trim() || undefined,
        repositories: repositories.length > 0 ? repositories : undefined,
      };

      try {
        const result = await createNewChain(data);
        if (result) {
          setIsCreating(false);
          navigate(`/chains/${result.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, repositories: AddChainRepositoryRequest[]) => void;
  onCancel: () => void;
}

type RepoMode = "remote" | "local" | null;

function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) {
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const [repositories, setRepositories] = useState<AddChainRepositoryRequest[]>([]);

  // Repository input state
  const [repoMode, setRepoMode] = useState<RepoMode>(null);
  const [repoName, setRepoName] = useState("");
  const [repoUrl, setRepoUrl] = useState("");
  const [repoPath, setRepoPath] = useState("");

  // Suggestions
  const [suggestions, setSuggestions] = useState<RepositoryHistoryEntry[]>([]);
  const [showSuggestions, setShowSuggestions] = useState(false);

  // Load suggestions when mode changes
  useEffect(() => {
    if (repoMode) {
      getRepositorySuggestions(repoMode, undefined, 10)
        .then((res) => {
          setSuggestions(res.entries);
          setShowSuggestions(res.entries.length > 0);
        })
        .catch(() => {
          setSuggestions([]);
          setShowSuggestions(false);
        });
    } else {
      setSuggestions([]);
      setShowSuggestions(false);
    }
  }, [repoMode]);

  const applySuggestion = (suggestion: RepositoryHistoryEntry) => {
    setRepoName(suggestion.name);
    if (suggestion.repositoryUrl) setRepoUrl(suggestion.repositoryUrl);
    if (suggestion.localPath) setRepoPath(suggestion.localPath);
    setShowSuggestions(false);
  };

  const handleAddRepo = () => {
    if (!repoName.trim()) return;
    if (repoMode === "remote" && !repoUrl.trim()) return;
    if (repoMode === "local" && !repoPath.trim()) return;

    const newRepo: AddChainRepositoryRequest = {
      name: repoName.trim(),
      sourceType: repoMode || "remote",
      isPrimary: repositories.length === 0, // First one is primary
      ...(repoMode === "remote" ? { repositoryUrl: repoUrl.trim() } : { localPath: repoPath.trim() }),
    };

    setRepositories([...repositories, newRepo]);
    setRepoMode(null);
    setRepoName("");
    setRepoUrl("");
    setRepoPath("");
  };

  const handleRemoveRepo = (index: number) => {
    const newRepos = repositories.filter((_, i) => i !== index);
    // If we removed the primary, make the first one primary
    if (newRepos.length > 0 && repositories[index]?.isPrimary) {
      newRepos[0].isPrimary = true;
    }
    setRepositories(newRepos);
  };

  const handleSubmit = () => {
    if (name.trim()) {
      onSubmit(name.trim(), description.trim(), repositories);
    }
  };

  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)] max-h-[90vh] overflow-y-auto">
        <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={2}
              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>

          {/* Repositories */}
          <div>
            <label className="block font-mono text-xs text-[#8b949e] uppercase mb-2">
              Repositories
            </label>

            {/* Added repositories */}
            {repositories.length > 0 && (
              <div className="space-y-2 mb-3">
                {repositories.map((repo, index) => (
                  <div
                    key={index}
                    className="flex items-center gap-2 px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)]"
                  >
                    <span className="font-mono text-[10px] text-[#556677] uppercase">
                      {repo.sourceType === "remote" ? "URL" : "Local"}
                    </span>
                    <span className="font-mono text-xs text-[#dbe7ff] flex-1 truncate">
                      {repo.name}
                    </span>
                    {repo.isPrimary && (
                      <span className="font-mono text-[8px] text-[#75aafc] uppercase px-1 border border-[#75aafc]/30">
                        primary
                      </span>
                    )}
                    <button
                      onClick={() => handleRemoveRepo(index)}
                      className="font-mono text-xs text-[#556677] hover:text-red-400"
                    >
                      ✕
                    </button>
                  </div>
                ))}
              </div>
            )}

            {/* Add repository form */}
            {repoMode ? (
              <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3">
                <div className="flex items-center justify-between">
                  <span className="font-mono text-[10px] text-[#75aafc] uppercase">
                    Add {repoMode === "remote" ? "Remote" : "Local"} Repository
                  </span>
                  {suggestions.length > 0 && (
                    <button
                      onClick={() => setShowSuggestions(!showSuggestions)}
                      className="font-mono text-[10px] text-[#556677] hover:text-[#9bc3ff]"
                    >
                      {showSuggestions ? "Hide" : `${suggestions.length} suggestions`}
                    </button>
                  )}
                </div>

                {/* Suggestions dropdown */}
                {showSuggestions && suggestions.length > 0 && (
                  <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-28 overflow-y-auto">
                    {suggestions.map((s) => (
                      <button
                        key={s.id}
                        onClick={() => applySuggestion(s)}
                        className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0"
                      >
                        <div className="flex items-center justify-between">
                          <span className="text-[#9bc3ff] truncate">{s.name}</span>
                          <span className="text-[10px] text-[#556677]">{s.useCount}×</span>
                        </div>
                        <div className="text-[10px] text-[#556677] truncate">
                          {repoMode === "local" ? s.localPath : s.repositoryUrl}
                        </div>
                      </button>
                    ))}
                  </div>
                )}

                <input
                  type="text"
                  value={repoName}
                  onChange={(e) => setRepoName(e.target.value)}
                  placeholder="Repository name"
                  className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]"
                />

                {repoMode === "remote" ? (
                  <input
                    type="text"
                    value={repoUrl}
                    onChange={(e) => setRepoUrl(e.target.value)}
                    placeholder="https://github.com/owner/repo"
                    className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]"
                  />
                ) : (
                  <input
                    type="text"
                    value={repoPath}
                    onChange={(e) => setRepoPath(e.target.value)}
                    placeholder="/path/to/repository"
                    className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]"
                  />
                )}

                <div className="flex gap-2">
                  <button
                    onClick={() => setRepoMode(null)}
                    className="px-3 py-1.5 font-mono text-xs text-[#556677] hover:text-[#9bc3ff]"
                  >
                    Cancel
                  </button>
                  <button
                    onClick={handleAddRepo}
                    disabled={
                      !repoName.trim() ||
                      (repoMode === "remote" && !repoUrl.trim()) ||
                      (repoMode === "local" && !repoPath.trim())
                    }
                    className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] disabled:opacity-50"
                  >
                    Add
                  </button>
                </div>
              </div>
            ) : (
              <div className="flex gap-2">
                <button
                  onClick={() => setRepoMode("remote")}
                  className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
                >
                  + Remote
                </button>
                <button
                  onClick={() => setRepoMode("local")}
                  className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
                >
                  + Local
                </button>
              </div>
            )}

            {repositories.length === 0 && !repoMode && (
              <p className="font-mono text-[10px] text-[#556677] mt-2">
                Add repositories that contracts in this chain will work with
              </p>
            )}
          </div>

          <p className="font-mono text-xs text-[#8b949e]">
            A chain links multiple contracts together in a DAG. Contracts depend on each
            other and start automatically when 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>
  );
}