feat: project create/edit/delete UI with folder picker and git clone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f096f7d7b0
commit
5b81361190
3 changed files with 255 additions and 3 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import AppLayout from "./components/layout/AppLayout";
|
import AppLayout from "./components/layout/AppLayout";
|
||||||
|
import ProjectForm from "./components/projects/ProjectForm";
|
||||||
|
import ProjectDashboard from "./components/projects/ProjectDashboard";
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -15,9 +17,9 @@ function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<EmptyState />} />
|
<Route index element={<EmptyState />} />
|
||||||
<Route path="/projects/new" element={<div className="p-8">Create project (coming next)</div>} />
|
<Route path="/projects/new" element={<ProjectForm />} />
|
||||||
<Route path="/projects/:projectId" element={<div className="p-8">Project dashboard (coming next)</div>} />
|
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
|
||||||
<Route path="/projects/:projectId/edit" element={<div className="p-8">Edit project (coming next)</div>} />
|
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
76
src/components/projects/ProjectDashboard.tsx
Normal file
76
src/components/projects/ProjectDashboard.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||||
|
import { getProject, deleteProject } from "../../lib/api";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
export default function ProjectDashboard() {
|
||||||
|
const { projectId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId) {
|
||||||
|
getProject(projectId).then(setProject);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!projectId) return;
|
||||||
|
if (!window.confirm(`Delete project "${project?.name}"?`)) return;
|
||||||
|
|
||||||
|
await deleteProject(projectId);
|
||||||
|
window.dispatchEvent(new Event("orchai:refresh-projects"));
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return <div className="p-8 text-gray-400">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">{project.name}</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/projects/${project.id}/edit`}
|
||||||
|
className="px-3 py-1 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500">Path:</span>
|
||||||
|
<span className="ml-2 text-sm font-mono">{project.path}</span>
|
||||||
|
</div>
|
||||||
|
{project.cloned_from && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500">Cloned from:</span>
|
||||||
|
<span className="ml-2 text-sm font-mono">{project.cloned_from}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500">Base branch:</span>
|
||||||
|
<span className="ml-2 text-sm font-mono">{project.base_branch}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500">Created:</span>
|
||||||
|
<span className="ml-2 text-sm">{new Date(project.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 text-gray-400 text-sm">
|
||||||
|
Tracker surveillance and ticket processing will be available in the next update.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/components/projects/ProjectForm.tsx
Normal file
174
src/components/projects/ProjectForm.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { createProject, getProject, updateProject } from "../../lib/api";
|
||||||
|
|
||||||
|
export default function ProjectForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { projectId } = useParams();
|
||||||
|
const isEditing = Boolean(projectId);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [pathOrUrl, setPathOrUrl] = useState("");
|
||||||
|
const [baseBranch, setBaseBranch] = useState("main");
|
||||||
|
const [mode, setMode] = useState<"local" | "clone">("local");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId) {
|
||||||
|
getProject(projectId).then((project) => {
|
||||||
|
setName(project.name);
|
||||||
|
setPathOrUrl(project.path);
|
||||||
|
setBaseBranch(project.base_branch);
|
||||||
|
if (project.cloned_from) {
|
||||||
|
setMode("clone");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
async function handleBrowse() {
|
||||||
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
if (selected) {
|
||||||
|
setPathOrUrl(selected as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing && projectId) {
|
||||||
|
await updateProject(projectId, name, baseBranch);
|
||||||
|
} else {
|
||||||
|
await createProject(name, pathOrUrl, baseBranch);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event("orchai:refresh-projects"));
|
||||||
|
navigate("/");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto p-8">
|
||||||
|
<h2 className="text-xl font-bold mb-6">
|
||||||
|
{isEditing ? "Edit project" : "New project"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Project name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Source
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={mode === "local"}
|
||||||
|
onChange={() => setMode("local")}
|
||||||
|
/>
|
||||||
|
Local folder
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={mode === "clone"}
|
||||||
|
onChange={() => setMode("clone")}
|
||||||
|
/>
|
||||||
|
Clone from URL
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{mode === "local" ? "Folder path" : "Git URL"}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pathOrUrl}
|
||||||
|
onChange={(e) => setPathOrUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder={
|
||||||
|
mode === "local"
|
||||||
|
? "/home/user/code/myproject"
|
||||||
|
: "https://github.com/org/repo.git"
|
||||||
|
}
|
||||||
|
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
{mode === "local" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBrowse}
|
||||||
|
className="px-3 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Base branch
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded p-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue