feat(ui): move app actions to sidebar and add sidebar user menu variant

Shift app-level actions (settings and user menu) from the top bar to the bottom
of the sidebar for improved navigation consistency. Introduce a new "sidebar"
variant for the UserMenu component with tailored styling and behavior for
sidebar placement. Update SettingsModal to support a customizable trigger label
and styling for sidebar integration. Refactor FinderSidebar to accept an
optional bottomSlot, enabling flexible placement of sidebar controls.
This commit is contained in:
Richard R 2026-05-28 14:58:03 -06:00
parent eea143abbf
commit c12f795026
5 changed files with 188 additions and 122 deletions

View file

@ -15,10 +15,13 @@ const Brand = () => (
);
const AppActions = () => (
<>
<SettingsModal />
<UserMenu />
</>
<div className="flex flex-col gap-0.5 w-full">
<SettingsModal
triggerLabel="Settings"
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
/>
<UserMenu variant="sidebar" />
</div>
);
export function HomeContent() {

View file

@ -130,7 +130,13 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [
type AdminSubTab = 'providers' | 'features';
export function SettingsModal({ className = '' }: { className?: string }) {
export function SettingsModal({
className = '',
triggerLabel,
}: {
className?: string;
triggerLabel?: string;
}) {
const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllProviderModels = runtimeConfig.showAllProviderModels;
@ -506,6 +512,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" />
{triggerLabel && <span className="ml-2">{triggerLabel}</span>}
</Button>
<Transition appear show={isOpen} as={Fragment}>

View file

@ -7,8 +7,17 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation';
import { UserIcon } from '@/components/icons/Icons';
export function UserMenu({ className = '' }: { className?: string }) {
type UserMenuVariant = 'toolbar' | 'sidebar';
export function UserMenu({
className = '',
variant = 'toolbar',
}: {
className?: string;
variant?: UserMenuVariant;
}) {
const { authEnabled, baseUrl } = useAuthConfig();
const enableUserSignups = useFeatureFlag('enableUserSignups');
const { data: session, isPending } = useAuthSession();
@ -22,7 +31,27 @@ export function UserMenu({ className = '' }: { className?: string }) {
router.push('/signin');
};
const rowClass =
'w-full inline-flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border border-transparent transition-all duration-200 ease-out text-left hover:scale-[1.01] hover:border-accent hover:bg-offbase hover:text-accent';
if (!session || session.user.isAnonymous) {
if (variant === 'sidebar') {
return (
<div className={`flex w-full flex-col gap-0.5 ${className}`}>
<Link href="/signin" className={rowClass}>
<UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate">Connect</span>
</Link>
{enableUserSignups && (
<Link href="/signup" className={rowClass}>
<UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate">Create account</span>
</Link>
)}
</div>
);
}
return (
<div className={`flex gap-2 ${className}`}>
<Link href="/signin">
@ -41,6 +70,24 @@ export function UserMenu({ className = '' }: { className?: string }) {
);
}
if (variant === 'sidebar') {
return (
<button
onClick={handleDisconnectAccount}
className={`${rowClass} ${className}`}
title="Disconnect account"
>
<UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate flex-1">{session.user.email || 'Account'}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
);
}
return (
<div className={`flex items-center gap-2 px-2 py-1 rounded-md border border-offbase bg-base ${className}`}>
<span className="hidden sm:block text-xs font-medium text-foreground truncate max-w-[160px]">

View file

@ -502,7 +502,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
isSidebarOpen={effectiveSidebarOpen}
isNarrow={isNarrow}
leftSlot={brand}
rightSlot={appActions}
/>
}
sidebar={
@ -521,6 +520,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
width={sidebarWidth}
onWidthChange={setSidebarWidth}
topSlot={<DocumentUploader variant="compact" />}
bottomSlot={appActions}
/>
}
statusBar={

View file

@ -22,6 +22,7 @@ interface FinderSidebarProps {
width: number;
onWidthChange: (px: number) => void;
topSlot?: ReactNode;
bottomSlot?: ReactNode;
}
const MIN_WIDTH = 168;
@ -180,6 +181,7 @@ export function FinderSidebar({
width,
onWidthChange,
topSlot,
bottomSlot,
}: FinderSidebarProps) {
const startRef = useRef<{ x: number; w: number } | null>(null);
@ -201,124 +203,131 @@ export function FinderSidebar({
return (
<aside
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-base border-r border-offbase shrink-0 overflow-y-auto"
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-base border-r border-offbase shrink-0 flex flex-col"
>
<div className="p-2 flex flex-col gap-0.5">
{topSlot && (
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{topSlot}
</div>
)}
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
<SidebarRow
active={filter === 'all'}
onClick={() => onFilterChange('all')}
icon={<HomeIcon className="w-3.5 h-3.5" />}
label="All Documents"
count={counts.all}
/>
<SidebarRow
active={filter === 'recents'}
onClick={() => onFilterChange('recents')}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recently Opened"
/>
<SectionHeader>Kinds</SectionHeader>
<SidebarRow
active={filter === 'pdf'}
onClick={() => onFilterChange('pdf')}
icon={<PDFIcon className="w-3.5 h-3.5" />}
label="PDF"
count={counts.pdf}
/>
<SidebarRow
active={filter === 'epub'}
onClick={() => onFilterChange('epub')}
icon={<EPUBIcon className="w-3.5 h-3.5" />}
label="EPUB"
count={counts.epub}
/>
<SidebarRow
active={filter === 'html'}
onClick={() => onFilterChange('html')}
icon={<FileIcon className="w-3.5 h-3.5" />}
label="HTML / Text"
count={counts.html}
/>
<SectionHeader
rightSlot={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-muted hover:text-accent transition-colors duration-200 ease-out focus:outline-none"
title="Folder actions"
aria-label="Folder actions"
>
<DotsHorizontalIcon className="w-4 h-2.5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
anchor="bottom start"
className="z-50 mt-2 min-w-[180px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1 normal-case tracking-normal font-normal"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={onNewFolder}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FolderPlusIcon className="h-4 w-4" />
New Folder
</button>
)}
</MenuItem>
<MenuItem disabled={folders.length === 0}>
{({ active, disabled }) => (
<button
type="button"
onClick={onClearFolders}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove All Folders
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="p-2 flex flex-col gap-0.5">
{topSlot && (
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{topSlot}
</div>
)}
>
Folders
</SectionHeader>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
) : (
folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => onFilterChange(`folder:${folder.id}`)}
onDelete={() => onDeleteFolder(folder.id)}
onDropOnFolder={onDropOnFolder}
/>
))
)}
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
<SidebarRow
active={filter === 'all'}
onClick={() => onFilterChange('all')}
icon={<HomeIcon className="w-3.5 h-3.5" />}
label="All Documents"
count={counts.all}
/>
<SidebarRow
active={filter === 'recents'}
onClick={() => onFilterChange('recents')}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recently Opened"
/>
<SectionHeader>Kinds</SectionHeader>
<SidebarRow
active={filter === 'pdf'}
onClick={() => onFilterChange('pdf')}
icon={<PDFIcon className="w-3.5 h-3.5" />}
label="PDF"
count={counts.pdf}
/>
<SidebarRow
active={filter === 'epub'}
onClick={() => onFilterChange('epub')}
icon={<EPUBIcon className="w-3.5 h-3.5" />}
label="EPUB"
count={counts.epub}
/>
<SidebarRow
active={filter === 'html'}
onClick={() => onFilterChange('html')}
icon={<FileIcon className="w-3.5 h-3.5" />}
label="HTML / Text"
count={counts.html}
/>
<SectionHeader
rightSlot={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-muted hover:text-accent transition-colors duration-200 ease-out focus:outline-none"
title="Folder actions"
aria-label="Folder actions"
>
<DotsHorizontalIcon className="w-4 h-2.5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
anchor="bottom start"
className="z-50 mt-2 min-w-[180px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1 normal-case tracking-normal font-normal"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={onNewFolder}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FolderPlusIcon className="h-4 w-4" />
New Folder
</button>
)}
</MenuItem>
<MenuItem disabled={folders.length === 0}>
{({ active, disabled }) => (
<button
type="button"
onClick={onClearFolders}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove All Folders
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
)}
>
Folders
</SectionHeader>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
) : (
folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => onFilterChange(`folder:${folder.id}`)}
onDelete={() => onDeleteFolder(folder.id)}
onDropOnFolder={onDropOnFolder}
/>
))
)}
</div>
</div>
{bottomSlot && (
<div className="shrink-0 border-t border-offbase p-2" onClick={(e) => e.stopPropagation()}>
{bottomSlot}
</div>
)}
<div
role="separator"