feat: foldable PROJECTS section in sidebar with color support

- Add `color` (text) and `archivedAt` (timestamp) columns to projects table
- Add PROJECT_COLORS palette constant (10 colors) in shared package
- Add color/archivedAt to Project type interface and Zod validators
- Auto-assign next available color from palette on project creation
- New SidebarProjects component with:
  - Collapsible PROJECTS header above WORK section
  - Caret toggle visible on hover (left of header)
  - Always-visible plus button (right of header) opens NewProjectDialog
  - Lists non-archived projects with colored rounded squares
  - Active project highlighted based on URL match
- Remove Projects nav item from WORK section in sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 09:14:08 -06:00
parent 3392f4dbfa
commit a57d3427f7
10 changed files with 224 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
import { eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { projects, projectGoals, goals } from "@paperclip/db";
import type { ProjectGoalRef } from "@paperclip/shared";
import { PROJECT_COLORS, type ProjectGoalRef } from "@paperclip/shared";
type ProjectRow = typeof projects.$inferSelect;
@@ -90,6 +90,14 @@ export function projectService(db: Db) {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
// Auto-assign a color from the palette if none provided
if (!projectData.color) {
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
projectData.color = nextColor;
}
// Also write goalId to the legacy column (first goal or null)
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;