From ab56a510cce241c89629f56b146afd0595aa6b3d Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 23 Feb 2026 09:56:31 -0600 Subject: [PATCH] feat: move project description to overview tab and add color picker - Moved description editing from header to Overview tab content - Header now shows colored rounded square + project name only - Added ColorPicker component: clicking the color square opens a popover with a 5x2 grid of PROJECT_COLORS to choose from - Current color is highlighted with a ring indicator Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/ProjectDetail.tsx | 291 ++++++++++++++++++++++++++------- 1 file changed, 228 insertions(+), 63 deletions(-) diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index d7cbbfd0..07345ac9 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,8 +1,11 @@ -import { useEffect } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useEffect, useMemo, useState, useRef } from "react"; +import { useParams, useNavigate, useLocation, Navigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { PROJECT_COLORS } from "@paperclip/shared"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; +import { agentsApi } from "../api/agents"; +import { heartbeatsApi } from "../api/heartbeats"; import { assetsApi } from "../api/assets"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -11,9 +14,174 @@ import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties } from "../components/ProjectProperties"; import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; -import { EntityRow } from "../components/EntityRow"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { Issue } from "@paperclip/shared"; +import { IssuesList } from "../components/IssuesList"; + +/* ── Top-level tab types ── */ + +type ProjectTab = "overview" | "list"; + +function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { + const prefix = `/projects/${projectId}`; + if (pathname === `${prefix}/overview`) return "overview"; + if (pathname.startsWith(`${prefix}/issues`)) return "list"; + return null; +} + +/* ── Overview tab content ── */ + +function OverviewContent({ + project, + onUpdate, + imageUploadHandler, +}: { + project: { description: string | null; status: string; targetDate: string | null }; + onUpdate: (data: Record) => void; + imageUploadHandler?: (file: File) => Promise; +}) { + return ( +
+ onUpdate({ description })} + as="p" + className="text-sm text-muted-foreground" + placeholder="Add a description..." + multiline + imageUploadHandler={imageUploadHandler} + /> + +
+
+ Status +
+ +
+
+ {project.targetDate && ( +
+ Target Date +

{project.targetDate}

+
+ )} +
+
+ ); +} + +/* ── Color picker popover ── */ + +function ColorPicker({ + currentColor, + onSelect, +}: { + currentColor: string; + onSelect: (color: string) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + return ( +
+
+ + )} + + ); +} + +/* ── List (issues) tab content ── */ + +function ProjectIssuesList({ projectId }: { projectId: string }) { + const { selectedCompanyId } = useCompany(); + const queryClient = useQueryClient(); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.liveRuns(selectedCompanyId!), + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), + enabled: !!selectedCompanyId, + refetchInterval: 5000, + }); + + const liveIssueIds = useMemo(() => { + const ids = new Set(); + for (const run of liveRuns ?? []) { + if (run.issueId) ids.add(run.issueId); + } + return ids; + }, [liveRuns]); + + const { data: issues, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId), + queryFn: () => issuesApi.list(selectedCompanyId!, { projectId }), + enabled: !!selectedCompanyId, + }); + + const updateIssue = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + issuesApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); + }, + }); + + return ( + updateIssue.mutate({ id, data })} + /> + ); +} + +/* ── Main project page ── */ export function ProjectDetail() { const { projectId } = useParams<{ projectId: string }>(); @@ -22,6 +190,9 @@ export function ProjectDetail() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const location = useLocation(); + + const activeTab = projectId ? resolveProjectTab(location.pathname, projectId) : null; const { data: project, isLoading, error } = useQuery({ queryKey: queryKeys.projects.detail(projectId!), @@ -29,14 +200,6 @@ export function ProjectDetail() { enabled: !!projectId, }); - const { data: allIssues } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); - - const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId); - const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId!) }); if (selectedCompanyId) { @@ -70,75 +233,77 @@ export function ProjectDetail() { return () => closePanel(); }, [project]); // eslint-disable-line react-hooks/exhaustive-deps + // Redirect bare /projects/:id to /projects/:id/issues + if (projectId && activeTab === null) { + return ; + } + if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!project) return null; + const handleTabChange = (tab: ProjectTab) => { + if (tab === "overview") { + navigate(`/projects/${projectId}/overview`); + } else { + navigate(`/projects/${projectId}/issues`); + } + }; + return (
-
+
+ updateProject.mutate({ color })} + /> updateProject.mutate({ name })} as="h2" className="text-xl font-bold" /> +
- updateProject.mutate({ description })} - as="p" - className="text-sm text-muted-foreground" - placeholder="Add a description..." - multiline + {/* Top-level project tabs */} +
+ + +
+ + {/* Tab content */} + {activeTab === "overview" && ( + updateProject.mutate(data)} imageUploadHandler={async (file) => { const asset = await uploadImage.mutateAsync(file); return asset.contentPath; }} /> -
+ )} - - - Overview - Issues ({projectIssues.length}) - - - -
-
- Status -
- -
-
- {project.targetDate && ( -
- Target Date -

{project.targetDate}

-
- )} -
-
- - - {projectIssues.length === 0 ? ( -

No issues in this project.

- ) : ( -
- {projectIssues.map((issue) => ( - } - onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)} - /> - ))} -
- )} -
-
+ {activeTab === "list" && projectId && ( + + )}
); }