feat: projects with context - schema, API, frontend page, task assignment (HQ-17, HQ-21)
This commit is contained in:
@@ -38,6 +38,29 @@ export interface ProgressNote {
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ─── Projects ───
|
||||
|
||||
export interface ProjectLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const projects = pgTable("projects", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
context: text("context"), // Architecture notes, how-to, credentials references
|
||||
repos: jsonb("repos").$type<string[]>().default([]), // Git repo URLs
|
||||
links: jsonb("links").$type<ProjectLink[]>().default([]), // Related URLs (docs, domains, dashboards)
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
|
||||
// ─── Tasks ───
|
||||
|
||||
export const tasks = pgTable("tasks", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
taskNumber: integer("task_number"),
|
||||
@@ -49,6 +72,7 @@ export const tasks = pgTable("tasks", {
|
||||
position: integer("position").notNull().default(0),
|
||||
assigneeId: text("assignee_id"),
|
||||
assigneeName: text("assignee_name"),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Elysia } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { taskRoutes } from "./routes/tasks";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { auth } from "./lib/auth";
|
||||
import { db } from "./db";
|
||||
import { tasks, users } from "./db/schema";
|
||||
@@ -113,6 +114,7 @@ const app = new Elysia()
|
||||
})
|
||||
|
||||
.use(taskRoutes)
|
||||
.use(projectRoutes)
|
||||
.use(adminRoutes)
|
||||
|
||||
// Current user info (role, etc.)
|
||||
|
||||
153
backend/src/routes/projects.ts
Normal file
153
backend/src/routes/projects.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { projects, tasks } from "../db/schema";
|
||||
import { eq, asc, desc } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(
|
||||
request: Request,
|
||||
headers: Record<string, string | undefined>
|
||||
) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session) return;
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
export const projectRoutes = new Elysia({ prefix: "/api/projects" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = error?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Project not found") {
|
||||
set.status = 404;
|
||||
return { error: "Project not found" };
|
||||
}
|
||||
console.error("Project route error:", msg);
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET all projects
|
||||
.get("/", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const allProjects = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.orderBy(asc(projects.name));
|
||||
return allProjects;
|
||||
})
|
||||
|
||||
// POST create project
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const newProject = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
context: body.context,
|
||||
repos: body.repos || [],
|
||||
links: body.links || [],
|
||||
})
|
||||
.returning();
|
||||
return newProject[0];
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
description: t.Optional(t.String()),
|
||||
context: t.Optional(t.String()),
|
||||
repos: t.Optional(t.Array(t.String())),
|
||||
links: t.Optional(
|
||||
t.Array(t.Object({ label: t.String(), url: t.String() }))
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// GET single project with its tasks
|
||||
.get(
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const project = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, params.id));
|
||||
if (!project.length) throw new Error("Project not found");
|
||||
|
||||
const projectTasks = await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.projectId, params.id))
|
||||
.orderBy(asc(tasks.position), desc(tasks.createdAt));
|
||||
|
||||
return { ...project[0], tasks: projectTasks };
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
)
|
||||
|
||||
// PATCH update project
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.description !== undefined) updates.description = body.description;
|
||||
if (body.context !== undefined) updates.context = body.context;
|
||||
if (body.repos !== undefined) updates.repos = body.repos;
|
||||
if (body.links !== undefined) updates.links = body.links;
|
||||
|
||||
const updated = await db
|
||||
.update(projects)
|
||||
.set(updates)
|
||||
.where(eq(projects.id, params.id))
|
||||
.returning();
|
||||
if (!updated.length) throw new Error("Project not found");
|
||||
return updated[0];
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
context: t.Optional(t.String()),
|
||||
repos: t.Optional(t.Array(t.String())),
|
||||
links: t.Optional(
|
||||
t.Array(t.Object({ label: t.String(), url: t.String() }))
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// DELETE project (unlinks tasks, doesn't delete them)
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
// Unlink tasks first
|
||||
await db
|
||||
.update(tasks)
|
||||
.set({ projectId: null, updatedAt: new Date() })
|
||||
.where(eq(tasks.projectId, params.id));
|
||||
// Delete project
|
||||
const deleted = await db
|
||||
.delete(projects)
|
||||
.where(eq(projects.id, params.id))
|
||||
.returning();
|
||||
if (!deleted.length) throw new Error("Project not found");
|
||||
return { success: true };
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
);
|
||||
@@ -152,6 +152,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
priority: body.priority || "medium",
|
||||
position: (maxPos[0]?.max ?? 0) + 1,
|
||||
taskNumber: nextNumber,
|
||||
projectId: body.projectId || null,
|
||||
progressNotes: [],
|
||||
})
|
||||
.returning();
|
||||
@@ -188,6 +189,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
t.Literal("low"),
|
||||
])
|
||||
),
|
||||
projectId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -233,6 +235,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
if (body.position !== undefined) updates.position = body.position;
|
||||
if (body.assigneeId !== undefined) updates.assigneeId = body.assigneeId;
|
||||
if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName;
|
||||
if (body.projectId !== undefined) updates.projectId = body.projectId;
|
||||
|
||||
const updated = await db
|
||||
.update(tasks)
|
||||
@@ -259,6 +262,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
position: t.Optional(t.Number()),
|
||||
assigneeId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
assigneeName: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
projectId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { DashboardLayout } from "./components/DashboardLayout";
|
||||
import { QueuePage } from "./pages/QueuePage";
|
||||
import { ChatPage } from "./pages/ChatPage";
|
||||
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||
import { AdminPage } from "./components/AdminPage";
|
||||
import { LoginPage } from "./components/LoginPage";
|
||||
import { useSession } from "./lib/auth-client";
|
||||
@@ -12,6 +13,7 @@ function AuthenticatedApp() {
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="*" element={<Navigate to="/queue" replace />} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { signOut } from "../lib/auth-client";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/queue", label: "Queue", icon: "📋" },
|
||||
{ to: "/projects", label: "Projects", icon: "📁" },
|
||||
{ to: "/chat", label: "Chat", icon: "💬" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource } from "../lib/types";
|
||||
import { updateTask } from "../lib/api";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types";
|
||||
import { updateTask, fetchProjects } from "../lib/api";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
@@ -250,6 +250,13 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
const [draftDescription, setDraftDescription] = useState(task.description || "");
|
||||
const [draftPriority, setDraftPriority] = useState(task.priority);
|
||||
const [draftSource, setDraftSource] = useState(task.source);
|
||||
const [draftProjectId, setDraftProjectId] = useState(task.projectId || "");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
// Fetch projects for the selector
|
||||
useEffect(() => {
|
||||
fetchProjects().then(setProjects).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Reset drafts when task changes
|
||||
useEffect(() => {
|
||||
@@ -257,31 +264,35 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
setDraftDescription(task.description || "");
|
||||
setDraftPriority(task.priority);
|
||||
setDraftSource(task.source);
|
||||
}, [task.id, task.title, task.description, task.priority, task.source]);
|
||||
setDraftProjectId(task.projectId || "");
|
||||
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId]);
|
||||
|
||||
// Detect if any field has been modified
|
||||
const isDirty =
|
||||
draftTitle !== task.title ||
|
||||
draftDescription !== (task.description || "") ||
|
||||
draftPriority !== task.priority ||
|
||||
draftSource !== task.source;
|
||||
draftSource !== task.source ||
|
||||
draftProjectId !== (task.projectId || "");
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraftTitle(task.title);
|
||||
setDraftDescription(task.description || "");
|
||||
setDraftPriority(task.priority);
|
||||
setDraftSource(task.source);
|
||||
setDraftProjectId(task.projectId || "");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasToken || !isDirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updates: Record<string, string> = {};
|
||||
const updates: Record<string, string | null> = {};
|
||||
if (draftTitle !== task.title) updates.title = draftTitle.trim();
|
||||
if (draftDescription !== (task.description || "")) updates.description = draftDescription.trim();
|
||||
if (draftPriority !== task.priority) updates.priority = draftPriority;
|
||||
if (draftSource !== task.source) updates.source = draftSource;
|
||||
if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null;
|
||||
await updateTask(task.id, updates, token);
|
||||
onTaskUpdated();
|
||||
} catch (e) {
|
||||
@@ -426,6 +437,29 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
{(hasToken || task.projectId) && (
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Project</h3>
|
||||
{hasToken ? (
|
||||
<select
|
||||
value={draftProjectId}
|
||||
onChange={(e) => setDraftProjectId(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white w-full max-w-xs"
|
||||
>
|
||||
<option value="">No project</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-sm text-gray-700">
|
||||
{projects.find((p) => p.id === task.projectId)?.name || task.projectId || "None"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task } from "./types";
|
||||
import type { Task, Project, ProjectWithTasks } from "./types";
|
||||
|
||||
const BASE = "/api/tasks";
|
||||
|
||||
@@ -64,6 +64,57 @@ export async function deleteTask(id: string, token?: string): Promise<void> {
|
||||
if (!res.ok) throw new Error("Failed to delete task");
|
||||
}
|
||||
|
||||
// ─── Projects API ───
|
||||
|
||||
const PROJECTS_BASE = "/api/projects";
|
||||
|
||||
export async function fetchProjects(): Promise<Project[]> {
|
||||
const res = await fetch(PROJECTS_BASE, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch projects");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchProject(id: string): Promise<ProjectWithTasks> {
|
||||
const res = await fetch(`${PROJECTS_BASE}/${id}`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Failed to fetch project");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
project: { name: string; description?: string; context?: string; repos?: string[]; links?: { label: string; url: string }[] }
|
||||
): Promise<Project> {
|
||||
const res = await fetch(PROJECTS_BASE, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(project),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create project");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
updates: Record<string, any>
|
||||
): Promise<Project> {
|
||||
const res = await fetch(`${PROJECTS_BASE}/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update project");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
const res = await fetch(`${PROJECTS_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete project");
|
||||
}
|
||||
|
||||
// Admin API
|
||||
export async function fetchUsers(): Promise<any[]> {
|
||||
const res = await fetch("/api/admin/users", { credentials: "include" });
|
||||
|
||||
@@ -7,6 +7,26 @@ export interface ProgressNote {
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface ProjectLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
context: string | null;
|
||||
repos: string[];
|
||||
links: ProjectLink[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectWithTasks extends Project {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
taskNumber: number;
|
||||
@@ -16,6 +36,7 @@ export interface Task {
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
position: number;
|
||||
projectId: string | null;
|
||||
progressNotes: ProgressNote[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
665
frontend/src/pages/ProjectsPage.tsx
Normal file
665
frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user