Docs hub's desktop sidebar is now drag-resizable. Mobile path is unchanged — still uses the FolderTreeSidebar Sheet drawer. - Extracted `FolderTreeBody` from `folder-tree-sidebar.tsx` so the same tree renders inside the mobile Sheet AND the desktop panel without forking the component. - `FolderTreeSidebar` is now mobile-only (just the Sheet trigger); documents-hub composes the desktop layout itself. - `<ResizablePanelGroup autoSaveId="documents-hub-split">` persists the user's chosen split width via localStorage automatically. Min 14% / max 40% defends against starvation. - shadcn-style `<Resizable*>` primitives in `src/components/ui/` match the rest of the UI kit; uses react-resizable-panels v3 (the v4 release renamed exports to `Group`/`Separator` and broke the shadcn convention — pinned v3 for now). Verified: tsc clean, vitest 1315/1315, next build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
6.3 KiB
TypeScript
213 lines
6.3 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { ChevronRight, Folder, FolderOpen, FolderTree, Inbox, Lock } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
|
import { cn } from '@/lib/utils';
|
|
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
|
|
|
interface FolderTreeSidebarProps {
|
|
/** Currently-selected folder id, or `null` for root, or `undefined`
|
|
* for "All documents" (no folder filter). */
|
|
selectedFolderId: string | null | undefined;
|
|
onSelect: (folderId: string | null | undefined) => void;
|
|
/** Slot below the tree for a "New folder" affordance from the parent. */
|
|
footer?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Mobile-only Sheet trigger that opens the folder tree in a drawer.
|
|
*
|
|
* Desktop rendering lives in `documents-hub.tsx` as one panel of a
|
|
* `<ResizablePanelGroup>` so power users on wide monitors can drag the
|
|
* split. Both surfaces render the same `<FolderTreeBody>` underneath.
|
|
*/
|
|
export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: FolderTreeSidebarProps) {
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
|
|
const handleMobileSelect = (id: string | null | undefined) => {
|
|
onSelect(id);
|
|
setMobileOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="sm:hidden px-3 pt-3">
|
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="outline" size="sm" className="min-h-[44px]">
|
|
<FolderTree className="mr-2 h-4 w-4" />
|
|
Show folders
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="w-3/4 max-w-xs p-0">
|
|
<SheetHeader className="border-b px-3 py-3">
|
|
<SheetTitle className="text-sm">Folders</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="p-2 overflow-y-auto">
|
|
<FolderTreeBody
|
|
selectedFolderId={selectedFolderId}
|
|
onSelect={handleMobileSelect}
|
|
footer={footer}
|
|
/>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FolderTreeBody({
|
|
selectedFolderId,
|
|
onSelect,
|
|
footer,
|
|
}: {
|
|
selectedFolderId: string | null | undefined;
|
|
onSelect: (folderId: string | null | undefined) => void;
|
|
footer?: React.ReactNode;
|
|
}) {
|
|
const { data: tree = [], isLoading, isError } = useDocumentFolders();
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-0.5">
|
|
<PseudoRow
|
|
label="All documents"
|
|
icon={Inbox}
|
|
active={selectedFolderId === undefined}
|
|
onClick={() => onSelect(undefined)}
|
|
/>
|
|
<PseudoRow
|
|
label="Root"
|
|
icon={Folder}
|
|
active={selectedFolderId === null}
|
|
onClick={() => onSelect(null)}
|
|
/>
|
|
</div>
|
|
<div className="mt-3 space-y-0.5">
|
|
{isLoading ? (
|
|
<p className="px-2 text-xs text-muted-foreground">Loading...</p>
|
|
) : isError ? (
|
|
<p className="px-2 text-xs text-destructive">Failed to load folders.</p>
|
|
) : tree.length === 0 ? (
|
|
<p className="px-2 text-xs text-muted-foreground">No folders yet.</p>
|
|
) : (
|
|
tree.map((node) => (
|
|
<FolderRow
|
|
key={node.id}
|
|
node={node}
|
|
depth={0}
|
|
selectedFolderId={selectedFolderId}
|
|
onSelect={onSelect}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
{footer ? <div className="mt-4 border-t pt-3">{footer}</div> : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PseudoRow({
|
|
label,
|
|
icon: Icon,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
icon: typeof Inbox;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
'w-full min-h-[44px] justify-start font-normal',
|
|
active && 'bg-accent text-foreground',
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<Icon className="mr-2 h-4 w-4" />
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function FolderRow({
|
|
node,
|
|
depth,
|
|
selectedFolderId,
|
|
onSelect,
|
|
}: {
|
|
node: FolderNode;
|
|
depth: number;
|
|
selectedFolderId: string | null | undefined;
|
|
onSelect: (folderId: string | null) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const hasChildren = node.children.length > 0;
|
|
const isActive = selectedFolderId === node.id;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
'group flex items-center gap-0.5 rounded-md px-1 py-0.5 text-sm',
|
|
isActive && 'bg-accent text-foreground',
|
|
)}
|
|
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={`${open ? 'Collapse' : 'Expand'} ${node.name}`}
|
|
aria-expanded={hasChildren ? open : undefined}
|
|
aria-hidden={!hasChildren}
|
|
tabIndex={hasChildren ? 0 : -1}
|
|
onClick={() => setOpen((o) => !o)}
|
|
className={cn(
|
|
'flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground hover:text-foreground',
|
|
!hasChildren && 'invisible',
|
|
)}
|
|
>
|
|
<ChevronRight className={cn('h-3.5 w-3.5 transition-transform', open && 'rotate-90')} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(node.id)}
|
|
className={cn(
|
|
'flex min-h-[44px] flex-1 items-center gap-1.5 truncate py-2 text-left',
|
|
node.archivedAt != null && 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{open && hasChildren ? (
|
|
<FolderOpen className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<Folder className="h-4 w-4 shrink-0" />
|
|
)}
|
|
<span className="truncate">
|
|
{node.name}
|
|
{node.systemManaged ? <span className="sr-only"> (system folder)</span> : null}
|
|
</span>
|
|
{node.systemManaged ? (
|
|
<Lock className="ml-1 h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
{open
|
|
? node.children.map((child) => (
|
|
<FolderRow
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
selectedFolderId={selectedFolderId}
|
|
onSelect={onSelect}
|
|
/>
|
|
))
|
|
: null}
|
|
</>
|
|
);
|
|
}
|