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.tsx496
-rw-r--r--makima/frontend/src/routes/directives.tsx4
2 files changed, 2 insertions, 498 deletions
diff --git a/makima/frontend/src/routes/chains.tsx b/makima/frontend/src/routes/chains.tsx
deleted file mode 100644
index 9b33304..0000000
--- a/makima/frontend/src/routes/chains.tsx
+++ /dev/null
@@ -1,496 +0,0 @@
-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>
- );
-}
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx
index 51fd57a..35e5703 100644
--- a/makima/frontend/src/routes/directives.tsx
+++ b/makima/frontend/src/routes/directives.tsx
@@ -769,7 +769,7 @@ function ChainTab({ directive, graph }: { directive: DirectiveWithProgress; grap
// Build edges from dependencies
const stepEdges: Edge[] = [];
directive.steps.forEach((step) => {
- step.dependsOn.forEach((depName) => {
+ (step.dependsOn ?? []).forEach((depName) => {
const depStep = directive.steps.find((s) => s.name === depName);
if (depStep) {
stepEdges.push({
@@ -919,7 +919,7 @@ function ChainTab({ directive, graph }: { directive: DirectiveWithProgress; grap
{step.description && (
<p className="font-mono text-xs text-[#556677] mt-1">{step.description}</p>
)}
- {step.dependsOn.length > 0 && (
+ {step.dependsOn?.length > 0 && (
<div className="font-mono text-[10px] text-[#556677] mt-1">
Depends on: {step.dependsOn.join(", ")}
</div>