666 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|