Files
hammer-queue/frontend/src/pages/ProjectsPage.tsx

666 lines
21 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import type { Project, ProjectWithTasks, Task } from "../lib/types";
import {
fetchProjects,
fetchProject,
createProject,
updateProject,
updateTask,
} from "../lib/api";
// ─── Status/priority helpers ───
const statusColors: Record<string, string> = {
active: "bg-green-100 text-green-700",
queued: "bg-blue-100 text-blue-700",
blocked: "bg-red-100 text-red-700",
completed: "bg-gray-100 text-gray-500",
cancelled: "bg-gray-100 text-gray-400",
};
function StatusBadge({ status }: { status: string }) {
return (
<span
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase ${statusColors[status] || "bg-gray-100 text-gray-500"}`}
>
{status}
</span>
);
}
// ─── Create Project Modal ───
function CreateProjectModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (p: Project) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [context, setContext] = useState("");
const [repos, setRepos] = useState("");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
const repoList = repos
.split("\n")
.map((r) => r.trim())
.filter(Boolean);
const project = await createProject({
name: name.trim(),
description: description.trim() || undefined,
context: context.trim() || undefined,
repos: repoList.length ? repoList : undefined,
});
onCreated(project);
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-900">New Project</h2>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200"
placeholder="e.g. Hammer Dashboard"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
placeholder="Brief description of the project"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Context{" "}
<span className="text-gray-400 font-normal">
(architecture, credentials refs, how-to)
</span>
</label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={5}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
placeholder={`Repo: https://gitea.donovankelly.xyz/...\nDomain: dash.donovankelly.xyz\nDokploy Compose ID: ...\nStack: React + Vite, Elysia + Bun, Postgres + Drizzle`}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Repos{" "}
<span className="text-gray-400 font-normal">(one per line)</span>
</label>
<textarea
value={repos}
onChange={(e) => setRepos(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
placeholder="https://gitea.donovankelly.xyz/donovan/hammer-queue"
/>
</div>
</div>
<div className="px-6 py-3 border-t border-gray-100 flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="px-4 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 transition font-medium"
>
{saving ? "Creating..." : "Create Project"}
</button>
</div>
</div>
</div>
);
}
// ─── Project Detail View ───
function ProjectDetail({
projectId,
onBack,
allTasks,
onTasksChanged,
}: {
projectId: string;
onBack: () => void;
allTasks: Task[];
onTasksChanged: () => void;
}) {
const [project, setProject] = useState<ProjectWithTasks | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const [editContext, setEditContext] = useState("");
const [editRepos, setEditRepos] = useState("");
const [saving, setSaving] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const load = useCallback(async () => {
try {
const data = await fetchProject(projectId);
setProject(data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
load();
}, [load]);
const startEdit = () => {
if (!project) return;
setEditName(project.name);
setEditDesc(project.description || "");
setEditContext(project.context || "");
setEditRepos((project.repos || []).join("\n"));
setEditing(true);
};
const saveEdit = async () => {
if (!project) return;
setSaving(true);
try {
const repoList = editRepos
.split("\n")
.map((r) => r.trim())
.filter(Boolean);
await updateProject(project.id, {
name: editName.trim(),
description: editDesc.trim() || null,
context: editContext.trim() || null,
repos: repoList,
});
setEditing(false);
load();
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const assignTask = async (taskId: string) => {
try {
await updateTask(taskId, { projectId });
load();
onTasksChanged();
} catch (e) {
console.error(e);
}
};
const unassignTask = async (taskId: string) => {
try {
await updateTask(taskId, { projectId: null });
load();
onTasksChanged();
} catch (e) {
console.error(e);
}
};
if (loading) {
return (
<div className="p-8 text-center text-gray-400">Loading project...</div>
);
}
if (!project) {
return (
<div className="p-8 text-center text-gray-400">Project not found</div>
);
}
// Unassigned tasks (not in this project)
const unassignedTasks = allTasks.filter(
(t) =>
!t.projectId &&
t.status !== "completed" &&
t.status !== "cancelled"
);
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={onBack}
className="p-1.5 rounded-lg hover:bg-gray-100 transition text-gray-400"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex-1">
{editing ? (
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="text-2xl font-bold text-gray-900 border-b-2 border-amber-400 focus:outline-none w-full"
/>
) : (
<h1 className="text-2xl font-bold text-gray-900">
{project.name}
</h1>
)}
</div>
{!editing && (
<button
onClick={startEdit}
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-3 py-1.5 rounded-lg hover:bg-amber-50 transition"
>
Edit
</button>
)}
</div>
{/* Edit mode */}
{editing ? (
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Context
</label>
<textarea
value={editContext}
onChange={(e) => setEditContext(e.target.value)}
rows={8}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Repos (one per line)
</label>
<textarea
value={editRepos}
onChange={(e) => setEditRepos(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={saveEdit}
disabled={saving || !editName.trim()}
className="px-4 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 font-medium"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
) : (
<>
{/* Description */}
{project.description && (
<p className="text-sm text-gray-600 mb-4">{project.description}</p>
)}
{/* Context card */}
{project.context && (
<div className="bg-gray-50 rounded-xl border border-gray-200 p-5 mb-6">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
📋 Context
</h3>
<pre className="text-sm text-gray-700 whitespace-pre-wrap font-mono leading-relaxed">
{project.context}
</pre>
</div>
)}
{/* Repos */}
{project.repos && project.repos.length > 0 && (
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
📦 Repos
</h3>
<div className="space-y-1">
{project.repos.map((repo, i) => (
<a
key={i}
href={repo}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-blue-600 hover:text-blue-800 font-mono truncate"
>
{repo}
</a>
))}
</div>
</div>
)}
{/* Links */}
{project.links && project.links.length > 0 && (
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
🔗 Links
</h3>
<div className="space-y-1">
{project.links.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-blue-600 hover:text-blue-800"
>
{link.label}{" "}
<span className="text-gray-400 font-mono text-xs">
({link.url})
</span>
</a>
))}
</div>
</div>
)}
</>
)}
{/* Tasks section */}
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
📝 Tasks ({project.tasks?.length || 0})
</h3>
<button
onClick={() => setShowAssign(!showAssign)}
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-2 py-1 rounded hover:bg-amber-50 transition"
>
{showAssign ? "Done" : "+ Assign Task"}
</button>
</div>
{/* Assign task dropdown */}
{showAssign && unassignedTasks.length > 0 && (
<div className="bg-white rounded-xl border border-amber-200 p-3 mb-4 max-h-48 overflow-y-auto">
<p className="text-xs text-gray-400 mb-2">
Select a task to assign to this project:
</p>
{unassignedTasks.map((task) => (
<button
key={task.id}
onClick={() => assignTask(task.id)}
className="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-amber-50 rounded-lg transition flex items-center gap-2"
>
<span className="text-gray-400 text-xs font-mono">
HQ-{task.taskNumber}
</span>
<span className="truncate">{task.title}</span>
<StatusBadge status={task.status} />
</button>
))}
</div>
)}
{showAssign && unassignedTasks.length === 0 && (
<div className="bg-gray-50 rounded-lg p-3 mb-4 text-xs text-gray-400 text-center">
All tasks are already assigned to projects
</div>
)}
{/* Task list */}
{project.tasks && project.tasks.length > 0 ? (
<div className="space-y-2">
{project.tasks.map((task) => (
<div
key={task.id}
className="bg-white rounded-lg border border-gray-200 px-4 py-3 flex items-center gap-3"
>
<span className="text-xs text-gray-400 font-mono shrink-0">
HQ-{task.taskNumber}
</span>
<span className="text-sm text-gray-800 flex-1 truncate">
{task.title}
</span>
<StatusBadge status={task.status} />
<button
onClick={() => unassignTask(task.id)}
className="text-gray-300 hover:text-red-400 transition shrink-0"
title="Remove from project"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
) : (
<div className="text-sm text-gray-400 text-center py-6 bg-gray-50 rounded-lg">
No tasks assigned to this project yet
</div>
)}
</div>
</div>
);
}
// ─── Project Card ───
function ProjectCard({
project,
taskCount,
onClick,
}: {
project: Project;
taskCount: number;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="w-full text-left bg-white rounded-xl border border-gray-200 p-5 hover:border-amber-300 hover:shadow-sm transition group"
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-base font-semibold text-gray-900 group-hover:text-amber-700 transition">
{project.name}
</h3>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full shrink-0 ml-2">
{taskCount} task{taskCount !== 1 ? "s" : ""}
</span>
</div>
{project.description && (
<p className="text-sm text-gray-500 line-clamp-2 mb-3">
{project.description}
</p>
)}
<div className="flex items-center gap-3">
{project.repos && project.repos.length > 0 && (
<span className="text-xs text-gray-400">
📦 {project.repos.length} repo
{project.repos.length !== 1 ? "s" : ""}
</span>
)}
{project.context && (
<span className="text-xs text-gray-400">📋 Has context</span>
)}
</div>
</button>
);
}
// ─── Main Page ───
export function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [allTasks, setAllTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const loadAll = useCallback(async () => {
try {
const [projectsData, tasksRes] = await Promise.all([
fetchProjects(),
fetch("/api/tasks", { credentials: "include" }).then((r) => r.json()),
]);
setProjects(projectsData);
setAllTasks(tasksRes);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
// Count tasks per project
const taskCountMap: Record<string, number> = {};
for (const task of allTasks) {
if (task.projectId) {
taskCountMap[task.projectId] = (taskCountMap[task.projectId] || 0) + 1;
}
}
if (selectedProject) {
return (
<div className="p-4 sm:p-6">
<ProjectDetail
projectId={selectedProject}
onBack={() => {
setSelectedProject(null);
loadAll();
}}
allTasks={allTasks}
onTasksChanged={loadAll}
/>
</div>
);
}
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">
Projects
</h1>
<p className="text-sm text-gray-500 mt-1">
Organize tasks by project with context for autonomous work
</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
>
+ New Project
</button>
</div>
{loading ? (
<div className="text-center text-gray-400 py-12">
Loading projects...
</div>
) : projects.length === 0 ? (
<div className="text-center py-16">
<span className="text-5xl block mb-4">📁</span>
<h2 className="text-lg font-semibold text-gray-600 mb-2">
No projects yet
</h2>
<p className="text-sm text-gray-400 mb-4">
Create a project to group tasks and add context
</p>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
>
Create First Project
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
taskCount={taskCountMap[project.id] || 0}
onClick={() => setSelectedProject(project.id)}
/>
))}
</div>
)}
{showCreate && (
<CreateProjectModal
onClose={() => setShowCreate(false)}
onCreated={(p) => {
setShowCreate(false);
setProjects((prev) => [...prev, p]);
}}
/>
)}
</div>
);
}