import { useCallback, useEffect, useMemo, useState } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { FolderOpen, Plus } from "lucide-react"; import { DndContext, MouseSensor, closestCenter, type DragEndEvent, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { cn, projectRouteRef } from "../lib/utils"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection"; import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; import { getProjectSortModeStorageKey, PROJECT_SORT_MODE_UPDATED_EVENT, readProjectSortMode, type ProjectSortModeUpdatedDetail, type ProjectSidebarSortMode, writeProjectSortMode, } from "../lib/project-order"; import type { Project } from "@paperclipai/shared"; type ProjectSidebarSlot = ReturnType["slots"][number]; const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [ { value: "top", label: "Top" }, { value: "alphabetical", label: "Alphabetical" }, { value: "recent", label: "Recent" }, ]; type ProjectItemProps = { activeProjectRef: string | null; companyId: string | null; companyPrefix: string | null; isMobile: boolean; project: Project; projectSidebarSlots: ProjectSidebarSlot[]; setSidebarOpen: (open: boolean) => void; isDragging?: boolean; }; function projectTimestamp(project: Project): number { const updated = new Date(project.updatedAt).getTime(); if (Number.isFinite(updated)) return updated; const created = new Date(project.createdAt).getTime(); return Number.isFinite(created) ? created : 0; } function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Project[] { if (sortMode === "top") return projects; const sorted = [...projects]; if (sortMode === "alphabetical") { sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" })); return sorted; } sorted.sort((left, right) => { const timeDiff = projectTimestamp(right) - projectTimestamp(left); return timeDiff !== 0 ? timeDiff : left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); }); return sorted; } function ProjectItem({ activeProjectRef, companyId, companyPrefix, isMobile, project, projectSidebarSlots, setSidebarOpen, isDragging = false, }: ProjectItemProps) { const routeRef = projectRouteRef(project); return (
{ if (isDragging) { e.preventDefault(); return; } if (isMobile) setSidebarOpen(false); }} className={cn( "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", activeProjectRef === routeRef || activeProjectRef === project.id ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-accent/50 hover:text-foreground", )} > {project.name} {project.pauseReason === "budget" ? : null} {projectSidebarSlots.length > 0 && (
{projectSidebarSlots.map((slot) => ( ))}
)}
); } function SortableProjectItem(props: ProjectItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: props.project.id }); return (
); } export function SidebarProjects() { const [open, setOpen] = useState(true); const { selectedCompany, selectedCompanyId } = useCompany(); const { openNewProject } = useDialogActions(); const { isMobile, setSidebarOpen } = useSidebar(); const location = useLocation(); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const { slots: projectSidebarSlots } = usePluginSlots({ slotTypes: ["projectSidebarItem"], entityType: "project", companyId: selectedCompanyId, enabled: !!selectedCompanyId, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const sortModeStorageKey = useMemo(() => { if (!selectedCompanyId) return null; return getProjectSortModeStorageKey(selectedCompanyId, currentUserId); }, [currentUserId, selectedCompanyId]); const [sortMode, setSortMode] = useState(() => { if (!sortModeStorageKey) return "top"; return readProjectSortMode(sortModeStorageKey); }); const visibleProjects = useMemo( () => (projects ?? []).filter((project: Project) => !project.archivedAt), [projects], ); const { orderedProjects, persistOrder } = useProjectOrder({ projects: visibleProjects, companyId: selectedCompanyId, userId: currentUserId, }); const sortedProjects = useMemo( () => sortProjects(orderedProjects, sortMode), [orderedProjects, sortMode], ); const isTopMode = sortMode === "top"; const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/); const activeProjectRef = projectMatch?.[1] ?? null; const sensors = useSensors( // Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior. useSensor(MouseSensor, { activationConstraint: { distance: 8 }, }), ); useEffect(() => { if (!sortModeStorageKey) { setSortMode("top"); return; } setSortMode(readProjectSortMode(sortModeStorageKey)); }, [sortModeStorageKey]); useEffect(() => { if (!sortModeStorageKey) return; const onStorage = (event: StorageEvent) => { if (event.key !== sortModeStorageKey) return; setSortMode(readProjectSortMode(sortModeStorageKey)); }; const onCustomEvent = (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail || detail.storageKey !== sortModeStorageKey) return; setSortMode(detail.sortMode); }; window.addEventListener("storage", onStorage); window.addEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent); return () => { window.removeEventListener("storage", onStorage); window.removeEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent); }; }, [sortModeStorageKey]); const persistSortMode = useCallback( (value: string) => { const nextSortMode: ProjectSidebarSortMode = value === "alphabetical" || value === "recent" ? value : "top"; setSortMode(nextSortMode); if (sortModeStorageKey) { writeProjectSortMode(sortModeStorageKey, nextSortMode); } }, [sortModeStorageKey], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { if (!isTopMode) return; const { active, over } = event; if (!over || active.id === over.id) return; const ids = orderedProjects.map((project) => project.id); const oldIndex = ids.indexOf(active.id as string); const newIndex = ids.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; persistOrder(arrayMove(ids, oldIndex, newIndex)); }, [isTopMode, orderedProjects, persistOrder], ); const renderProject = (project: Project) => ( ); return ( {isTopMode ? ( project.id)} strategy={verticalListSortingStrategy} >
{orderedProjects.map((project: Project) => ( ))}
) : (
{sortedProjects.map((project: Project) => renderProject(project))}
)}
); }