mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Addresses board feedback on the collapsible sidebar: - Company logo no longer shifts on collapse. The workspace-switcher Button's default size includes `has-[>svg]:px-3`, so with the chevron svg present (expanded) its padding was 12px but in the rail (chevron hidden) it fell back to the `px-2` of 8px — a 4px horizontal jump. Pinning it to `px-3` keeps the logo's left edge in the icon column in both states. - Selected-item highlight is now symmetric in the rail. The icon sits at left=24 (nav px-3 + item px-3); a 56px rail left only 4px of trailing space so the active background looked clipped on the right / heavy on the left. Rail width is now 64 (24 + 16 icon + 24), centering the icon and making the full-width highlight symmetric around it. Icons still don't move (left edge unchanged). The cramped rail also clipped the search/toggle controls, so they are dropped in the rail (expansion stays available via hover-peek + Pin and Cmd/Ctrl+B); they return when expanded or peeking. - Peek no longer collapses on navigation. Removed the close-peek-on-pathname effect and guarded the panel blur handler with a pointer-inside ref: clicking a nav item moves focus to <main>, but while the pointer is still over the flyout the peek stays open until the user actually mouses off. Verified by rendering the real Sidebar at the 64px rail: company logo and every nav icon align to left=25 in both states (dX=0), the active highlight is centered/symmetric, and the icon-no-move guarantee still holds (max |delta| 1px, rounding). 60 sidebar tests + Layout tests pass; tsc clean. Co-Authored-By: Paperclip <noreply@paperclip.ing>
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Check,
|
|
ChevronsUpDown,
|
|
GripVertical,
|
|
LogOut,
|
|
Plus,
|
|
Settings,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import {
|
|
DndContext,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
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 type { Company } from "@paperclipai/shared";
|
|
import { Link, useLocation, useNavigate } from "@/lib/router";
|
|
import { authApi } from "@/api/auth";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useCompany } from "@/context/CompanyContext";
|
|
import { useDialogActions } from "@/context/DialogContext";
|
|
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { cn } from "@/lib/utils";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
|
|
|
interface SidebarCompanyMenuProps {
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
function WorkspaceIcon({ company }: { company: Company }) {
|
|
return (
|
|
<CompanyPatternIcon
|
|
companyName={company.name}
|
|
logoUrl={company.logoUrl}
|
|
brandColor={company.brandColor}
|
|
className="size-5 shrink-0 rounded-md text-[11px]"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SortableCompanyItem({
|
|
company,
|
|
isEditing,
|
|
isSelected,
|
|
onSelect,
|
|
}: {
|
|
company: Company;
|
|
isEditing: boolean;
|
|
isSelected: boolean;
|
|
onSelect: (company: Company) => void;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setActivatorNodeRef,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: company.id, disabled: !isEditing });
|
|
|
|
return (
|
|
<DropdownMenuItem
|
|
ref={setNodeRef}
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 10 : undefined,
|
|
}}
|
|
onSelect={(event) => {
|
|
if (isEditing) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
onSelect(company);
|
|
}}
|
|
className={cn(
|
|
"min-w-0 gap-2 py-2",
|
|
isEditing && "cursor-grab",
|
|
isDragging && "opacity-80",
|
|
isSelected && "bg-accent text-accent-foreground",
|
|
)}
|
|
>
|
|
<WorkspaceIcon company={company} />
|
|
<span className="min-w-0 flex-1 truncate">{company.name}</span>
|
|
{isEditing ? (
|
|
<button
|
|
type="button"
|
|
ref={setActivatorNodeRef}
|
|
aria-label={`Reorder ${company.name}`}
|
|
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical className="size-4" aria-hidden="true" />
|
|
</button>
|
|
) : (
|
|
<>
|
|
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
{company.issuePrefix}
|
|
</span>
|
|
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const [isEditingOrder, setIsEditingOrder] = useState(false);
|
|
const queryClient = useQueryClient();
|
|
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
|
const { openOnboarding } = useDialogActions();
|
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
|
const rail = collapsed && !peeking;
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const open = controlledOpen ?? internalOpen;
|
|
const setOpen = onOpenChange ?? setInternalOpen;
|
|
const sensors = useSensors(
|
|
useSensor(MouseSensor, {
|
|
activationConstraint: { distance: 8 },
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: { delay: 180, tolerance: 6 },
|
|
}),
|
|
);
|
|
const sidebarCompanies = useMemo(
|
|
() => companies.filter((company) => company.status !== "archived"),
|
|
[companies],
|
|
);
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
retry: false,
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
|
companies: sidebarCompanies,
|
|
userId: currentUserId,
|
|
});
|
|
|
|
const signOutMutation = useMutation({
|
|
mutationFn: () => authApi.signOut(),
|
|
onSuccess: async () => {
|
|
setOpen(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
|
},
|
|
});
|
|
|
|
function handleOpenChange(nextOpen: boolean) {
|
|
if (!nextOpen) setIsEditingOrder(false);
|
|
setOpen(nextOpen);
|
|
}
|
|
|
|
function closeNavigationChrome() {
|
|
setOpen(false);
|
|
setIsEditingOrder(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}
|
|
|
|
function selectCompany(company: Company) {
|
|
const pathPrefix = location.pathname.split("/")[1]?.toUpperCase();
|
|
const isCompanyRoute = sidebarCompanies.some((sidebarCompany) => (
|
|
sidebarCompany.issuePrefix.toUpperCase() === pathPrefix
|
|
));
|
|
const shouldLeaveCurrentRoute = company.id !== selectedCompany?.id
|
|
&& (location.pathname.startsWith("/instance/") || isCompanyRoute);
|
|
|
|
setSelectedCompanyId(company.id);
|
|
setOpen(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
if (shouldLeaveCurrentRoute) {
|
|
navigate(`/${company.issuePrefix}/dashboard`);
|
|
}
|
|
}
|
|
|
|
function addCompany() {
|
|
setOpen(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
openOnboarding();
|
|
}
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const ids = orderedCompanies.map((company) => company.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));
|
|
},
|
|
[orderedCompanies, persistOrder],
|
|
);
|
|
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
// `px-3` (not px-2) so the logo's left edge lines up with the nav icon
|
|
// column (nav px-3 + item px-3) and, crucially, stays put between states:
|
|
// the Button's default size adds `has-[>svg]:px-3`, so with the chevron
|
|
// svg present (expanded) it was already 12px but without it (rail) it fell
|
|
// back to 8px — a 4px horizontal jump on collapse (PAP-10676).
|
|
className="h-9 flex-1 justify-start gap-2 px-3 text-left"
|
|
aria-label={selectedCompany ? `Open ${selectedCompany.name} workspace switcher` : "Open workspace switcher"}
|
|
>
|
|
<span className="flex min-w-0 flex-1 items-center gap-2">
|
|
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
|
<span className={cn("truncate text-sm font-bold text-foreground", rail && "sr-only")}>
|
|
{selectedCompany?.name ?? "Select workspace"}
|
|
</span>
|
|
</span>
|
|
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
|
|
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
|
<DropdownMenuLabel className="p-0 text-[11px] font-semibold uppercase text-muted-foreground">
|
|
Switch workspace
|
|
</DropdownMenuLabel>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setIsEditingOrder((current) => !current);
|
|
}}
|
|
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
>
|
|
{isEditingOrder ? "Done" : "Edit"}
|
|
</button>
|
|
</div>
|
|
<div className="max-h-96 overflow-y-auto">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={orderedCompanies.map((company) => company.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{orderedCompanies.map((company) => (
|
|
<SortableCompanyItem
|
|
key={company.id}
|
|
company={company}
|
|
isEditing={isEditingOrder}
|
|
isSelected={company.id === selectedCompany?.id}
|
|
onSelect={selectCompany}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
{orderedCompanies.length === 0 ? (
|
|
<DropdownMenuItem disabled>No workspaces</DropdownMenuItem>
|
|
) : null}
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={addCompany}
|
|
className="gap-2 py-2 text-muted-foreground"
|
|
disabled={isEditingOrder}
|
|
>
|
|
<Plus className="size-4" />
|
|
<span>Add company...</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
|
<Link
|
|
to="/company/settings/invites"
|
|
onClick={(event) => {
|
|
if (isEditingOrder) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
closeNavigationChrome();
|
|
}}
|
|
>
|
|
<UserPlus className="size-4" />
|
|
<span className="truncate">
|
|
{selectedCompany ? `Invite people to ${selectedCompany.name}` : "Invite people"}
|
|
</span>
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
|
<Link
|
|
to="/company/settings"
|
|
onClick={(event) => {
|
|
if (isEditingOrder) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
closeNavigationChrome();
|
|
}}
|
|
>
|
|
<Settings className="size-4" />
|
|
<span>Company settings</span>
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
{session?.session ? (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => signOutMutation.mutate()}
|
|
disabled={isEditingOrder || signOutMutation.isPending}
|
|
>
|
|
<LogOut className="size-4" />
|
|
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : null}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|