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:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "projects" ADD COLUMN "color" text;
|
||||||
|
ALTER TABLE "projects" ADD COLUMN "archived_at" timestamp with time zone;
|
||||||
@@ -99,6 +99,20 @@
|
|||||||
"when": 1771623691139,
|
"when": 1771623691139,
|
||||||
"tag": "0013_dashing_wasp",
|
"tag": "0013_dashing_wasp",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771691806349,
|
||||||
|
"tag": "0014_many_mikhail_rasputin",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771865100000,
|
||||||
|
"tag": "0015_project_color_archived",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ export const projects = pgTable(
|
|||||||
status: text("status").notNull().default("backlog"),
|
status: text("status").notNull().default("backlog"),
|
||||||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||||
targetDate: date("target_date"),
|
targetDate: date("target_date"),
|
||||||
|
color: text("color"),
|
||||||
|
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
|
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
|
||||||
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
|
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
|
||||||
|
|
||||||
|
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
|
||||||
|
export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
|
||||||
|
|
||||||
|
export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const;
|
||||||
|
export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number];
|
||||||
|
|
||||||
|
export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const;
|
||||||
|
export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number];
|
||||||
|
|
||||||
export const AGENT_STATUSES = [
|
export const AGENT_STATUSES = [
|
||||||
"active",
|
"active",
|
||||||
"paused",
|
"paused",
|
||||||
@@ -59,6 +68,19 @@ export const PROJECT_STATUSES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||||
|
|
||||||
|
export const PROJECT_COLORS = [
|
||||||
|
"#6366f1", // indigo
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#ef4444", // red
|
||||||
|
"#f97316", // orange
|
||||||
|
"#eab308", // yellow
|
||||||
|
"#22c55e", // green
|
||||||
|
"#14b8a6", // teal
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#3b82f6", // blue
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy"] as const;
|
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy"] as const;
|
||||||
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
|
||||||
|
|
||||||
@@ -124,3 +146,34 @@ export const LIVE_EVENT_TYPES = [
|
|||||||
"activity.logged",
|
"activity.logged",
|
||||||
] as const;
|
] as const;
|
||||||
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
||||||
|
|
||||||
|
export const PRINCIPAL_TYPES = ["user", "agent"] as const;
|
||||||
|
export type PrincipalType = (typeof PRINCIPAL_TYPES)[number];
|
||||||
|
|
||||||
|
export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const;
|
||||||
|
export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number];
|
||||||
|
|
||||||
|
export const INSTANCE_USER_ROLES = ["instance_admin"] as const;
|
||||||
|
export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number];
|
||||||
|
|
||||||
|
export const INVITE_TYPES = ["company_join", "bootstrap_ceo"] as const;
|
||||||
|
export type InviteType = (typeof INVITE_TYPES)[number];
|
||||||
|
|
||||||
|
export const INVITE_JOIN_TYPES = ["human", "agent", "both"] as const;
|
||||||
|
export type InviteJoinType = (typeof INVITE_JOIN_TYPES)[number];
|
||||||
|
|
||||||
|
export const JOIN_REQUEST_TYPES = ["human", "agent"] as const;
|
||||||
|
export type JoinRequestType = (typeof JOIN_REQUEST_TYPES)[number];
|
||||||
|
|
||||||
|
export const JOIN_REQUEST_STATUSES = ["pending_approval", "approved", "rejected"] as const;
|
||||||
|
export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number];
|
||||||
|
|
||||||
|
export const PERMISSION_KEYS = [
|
||||||
|
"agents:create",
|
||||||
|
"users:invite",
|
||||||
|
"users:manage_permissions",
|
||||||
|
"tasks:assign",
|
||||||
|
"tasks:assign_scope",
|
||||||
|
"joins:approve",
|
||||||
|
] as const;
|
||||||
|
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
COMPANY_STATUSES,
|
COMPANY_STATUSES,
|
||||||
|
DEPLOYMENT_MODES,
|
||||||
|
DEPLOYMENT_EXPOSURES,
|
||||||
|
AUTH_BASE_URL_MODES,
|
||||||
AGENT_STATUSES,
|
AGENT_STATUSES,
|
||||||
AGENT_ADAPTER_TYPES,
|
AGENT_ADAPTER_TYPES,
|
||||||
AGENT_ROLES,
|
AGENT_ROLES,
|
||||||
@@ -8,6 +11,7 @@ export {
|
|||||||
GOAL_LEVELS,
|
GOAL_LEVELS,
|
||||||
GOAL_STATUSES,
|
GOAL_STATUSES,
|
||||||
PROJECT_STATUSES,
|
PROJECT_STATUSES,
|
||||||
|
PROJECT_COLORS,
|
||||||
APPROVAL_TYPES,
|
APPROVAL_TYPES,
|
||||||
APPROVAL_STATUSES,
|
APPROVAL_STATUSES,
|
||||||
SECRET_PROVIDERS,
|
SECRET_PROVIDERS,
|
||||||
@@ -17,7 +21,18 @@ export {
|
|||||||
WAKEUP_TRIGGER_DETAILS,
|
WAKEUP_TRIGGER_DETAILS,
|
||||||
WAKEUP_REQUEST_STATUSES,
|
WAKEUP_REQUEST_STATUSES,
|
||||||
LIVE_EVENT_TYPES,
|
LIVE_EVENT_TYPES,
|
||||||
|
PRINCIPAL_TYPES,
|
||||||
|
MEMBERSHIP_STATUSES,
|
||||||
|
INSTANCE_USER_ROLES,
|
||||||
|
INVITE_TYPES,
|
||||||
|
INVITE_JOIN_TYPES,
|
||||||
|
JOIN_REQUEST_TYPES,
|
||||||
|
JOIN_REQUEST_STATUSES,
|
||||||
|
PERMISSION_KEYS,
|
||||||
type CompanyStatus,
|
type CompanyStatus,
|
||||||
|
type DeploymentMode,
|
||||||
|
type DeploymentExposure,
|
||||||
|
type AuthBaseUrlMode,
|
||||||
type AgentStatus,
|
type AgentStatus,
|
||||||
type AgentAdapterType,
|
type AgentAdapterType,
|
||||||
type AgentRole,
|
type AgentRole,
|
||||||
@@ -35,6 +50,14 @@ export {
|
|||||||
type WakeupTriggerDetail,
|
type WakeupTriggerDetail,
|
||||||
type WakeupRequestStatus,
|
type WakeupRequestStatus,
|
||||||
type LiveEventType,
|
type LiveEventType,
|
||||||
|
type PrincipalType,
|
||||||
|
type MembershipStatus,
|
||||||
|
type InstanceUserRole,
|
||||||
|
type InviteType,
|
||||||
|
type InviteJoinType,
|
||||||
|
type JoinRequestType,
|
||||||
|
type JoinRequestStatus,
|
||||||
|
type PermissionKey,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -68,6 +91,11 @@ export type {
|
|||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
SidebarBadges,
|
SidebarBadges,
|
||||||
|
CompanyMembership,
|
||||||
|
PrincipalPermissionGrant,
|
||||||
|
Invite,
|
||||||
|
JoinRequest,
|
||||||
|
InstanceUserRoleGrant,
|
||||||
EnvBinding,
|
EnvBinding,
|
||||||
AgentEnvConfig,
|
AgentEnvConfig,
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
@@ -139,9 +167,19 @@ export {
|
|||||||
createCostEventSchema,
|
createCostEventSchema,
|
||||||
updateBudgetSchema,
|
updateBudgetSchema,
|
||||||
createAssetImageMetadataSchema,
|
createAssetImageMetadataSchema,
|
||||||
|
createCompanyInviteSchema,
|
||||||
|
acceptInviteSchema,
|
||||||
|
listJoinRequestsQuerySchema,
|
||||||
|
updateMemberPermissionsSchema,
|
||||||
|
updateUserCompanyAccessSchema,
|
||||||
type CreateCostEvent,
|
type CreateCostEvent,
|
||||||
type UpdateBudget,
|
type UpdateBudget,
|
||||||
type CreateAssetImageMetadata,
|
type CreateAssetImageMetadata,
|
||||||
|
type CreateCompanyInvite,
|
||||||
|
type AcceptInvite,
|
||||||
|
type ListJoinRequestsQuery,
|
||||||
|
type UpdateMemberPermissions,
|
||||||
|
type UpdateUserCompanyAccess,
|
||||||
} from "./validators/index.js";
|
} from "./validators/index.js";
|
||||||
|
|
||||||
export { API_PREFIX, API } from "./api.js";
|
export { API_PREFIX, API } from "./api.js";
|
||||||
@@ -153,6 +191,7 @@ export {
|
|||||||
databaseConfigSchema,
|
databaseConfigSchema,
|
||||||
loggingConfigSchema,
|
loggingConfigSchema,
|
||||||
serverConfigSchema,
|
serverConfigSchema,
|
||||||
|
authConfigSchema,
|
||||||
secretsConfigSchema,
|
secretsConfigSchema,
|
||||||
storageConfigSchema,
|
storageConfigSchema,
|
||||||
storageLocalDiskConfigSchema,
|
storageLocalDiskConfigSchema,
|
||||||
@@ -163,6 +202,7 @@ export {
|
|||||||
type DatabaseConfig,
|
type DatabaseConfig,
|
||||||
type LoggingConfig,
|
type LoggingConfig,
|
||||||
type ServerConfig,
|
type ServerConfig,
|
||||||
|
type AuthConfig,
|
||||||
type StorageConfig,
|
type StorageConfig,
|
||||||
type StorageLocalDiskConfig,
|
type StorageLocalDiskConfig,
|
||||||
type StorageS3Config,
|
type StorageS3Config,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface Project {
|
|||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
leadAgentId: string | null;
|
leadAgentId: string | null;
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
|
color: string | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const createProjectSchema = z.object({
|
|||||||
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
|
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
|
||||||
leadAgentId: z.string().uuid().optional().nullable(),
|
leadAgentId: z.string().uuid().optional().nullable(),
|
||||||
targetDate: z.string().optional().nullable(),
|
targetDate: z.string().optional().nullable(),
|
||||||
|
color: z.string().optional().nullable(),
|
||||||
|
archivedAt: z.string().datetime().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateProject = z.infer<typeof createProjectSchema>;
|
export type CreateProject = z.infer<typeof createProjectSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import { projects, projectGoals, goals } 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;
|
type ProjectRow = typeof projects.$inferSelect;
|
||||||
|
|
||||||
@@ -90,6 +90,14 @@ export function projectService(db: Db) {
|
|||||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
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)
|
// Also write goalId to the legacy column (first goal or null)
|
||||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Hexagon,
|
|
||||||
Target,
|
Target,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
History,
|
History,
|
||||||
Search,
|
Search,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
ShieldCheck,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -17,6 +15,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { CompanySwitcher } from "./CompanySwitcher";
|
import { CompanySwitcher } from "./CompanySwitcher";
|
||||||
import { SidebarSection } from "./SidebarSection";
|
import { SidebarSection } from "./SidebarSection";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
|
import { SidebarProjects } from "./SidebarProjects";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||||
@@ -82,20 +81,15 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SidebarProjects />
|
||||||
|
|
||||||
<SidebarSection label="Work">
|
<SidebarSection label="Work">
|
||||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||||
<SidebarNavItem to="/projects" label="Projects" icon={Hexagon} />
|
|
||||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
||||||
<SidebarNavItem
|
|
||||||
to="/approvals"
|
|
||||||
label="Approvals"
|
|
||||||
icon={ShieldCheck}
|
|
||||||
badge={sidebarBadges?.approvals}
|
|
||||||
/>
|
|
||||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|||||||
97
ui/src/components/SidebarProjects.tsx
Normal file
97
ui/src/components/SidebarProjects.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronRight, Plus } from "lucide-react";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useDialog } from "../context/DialogContext";
|
||||||
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import type { Project } from "@paperclip/shared";
|
||||||
|
|
||||||
|
export function SidebarProjects() {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { openNewProject } = useDialog();
|
||||||
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out archived projects
|
||||||
|
const visibleProjects = (projects ?? []).filter(
|
||||||
|
(p: Project) => !p.archivedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract current projectId from URL
|
||||||
|
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/);
|
||||||
|
const activeProjectId = projectMatch?.[1] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<div className="group">
|
||||||
|
<div className="flex items-center px-3 py-1.5">
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 text-muted-foreground/60 transition-transform opacity-0 group-hover:opacity-100",
|
||||||
|
open && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60">
|
||||||
|
Projects
|
||||||
|
</span>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openNewProject();
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center h-4 w-4 rounded text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||||
|
aria-label="New project"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||||
|
{visibleProjects.map((project: Project) => (
|
||||||
|
<NavLink
|
||||||
|
key={project.id}
|
||||||
|
to={`/projects/${project.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||||
|
activeProjectId === project.id
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: project.color ?? "#6366f1",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{project.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user