import { useCallback, useEffect, useMemo, useState } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { FolderOpen, Loader2, LogOut, MoreHorizontal, 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 { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; 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" }, ]; const REORDER_POINTER_MEDIA = "(hover: hover) and (pointer: fine)"; type ProjectItemProps = { activeProjectRef: string | null; companyId: string | null; companyPrefix: string | null; isMobile: boolean; project: Project; projectSidebarSlots: ProjectSidebarSlot[]; setSidebarOpen: (open: boolean) => void; onLeaveProject: (project: Project) => void; leaving?: boolean; 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 hasFineReorderPointer() { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true; return window.matchMedia(REORDER_POINTER_MEDIA).matches; } function useFineReorderPointer() { const [matches, setMatches] = useState(hasFineReorderPointer); useEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; const query = window.matchMedia(REORDER_POINTER_MEDIA); const onChange = (event: MediaQueryListEvent) => setMatches(event.matches); setMatches(query.matches); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }, []); return matches; } function ProjectItem({ activeProjectRef, companyId, companyPrefix, isMobile, project, projectSidebarSlots, setSidebarOpen, onLeaveProject, leaving = false, isDragging = false, }: ProjectItemProps) { const routeRef = projectRouteRef(project); return (
{ if (isDragging) { e.preventDefault(); return; } if (isMobile) setSidebarOpen(false); }} className={cn( "flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 pointer-coarse:py-1 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} { if (leaving) return; onLeaveProject(project); }} disabled={leaving} > {leaving ? : } {leaving ? "Leaving..." : "Leave project"}
{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 fineReorderPointer = useFineReorderPointer(); const location = useLocation(); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const membershipsQuery = useResourceMemberships(selectedCompanyId); const membershipMutation = useResourceMembershipMutation(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) => { if (project.archivedAt) return false; if (!membershipsQuery.isSuccess) return true; return resourceMembershipState(membershipsQuery.data, "project", project.id) !== "left"; }), [membershipsQuery.data, membershipsQuery.isSuccess, projects], ); const { orderedProjects, persistOrder } = useProjectOrder({ projects: visibleProjects, companyId: selectedCompanyId, userId: currentUserId, }); const sortedProjects = useMemo( () => sortProjects(orderedProjects, sortMode), [orderedProjects, sortMode], ); const isTopMode = sortMode === "top"; const canReorderProjects = isTopMode && !isMobile && fineReorderPointer; 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 leaveProject = useCallback( (project: Project) => membershipMutation.mutate({ resourceType: "project", resourceId: project.id, resourceName: project.name, state: "left", }), [membershipMutation], ); const projectLeaving = useCallback( (project: Project) => membershipMutation.isPending && membershipMutation.variables?.resourceType === "project" && membershipMutation.variables.resourceId === project.id, [membershipMutation.isPending, membershipMutation.variables], ); const renderProject = (project: Project) => ( ); return ( {canReorderProjects ? ( project.id)} strategy={verticalListSortingStrategy} >
{orderedProjects.map((project: Project) => ( ))}
) : (
{sortedProjects.map((project: Project) => renderProject(project))}
)}
); }