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:
parent
eea143abbf
commit
c12f795026
5 changed files with 188 additions and 122 deletions
|
|
@ -15,10 +15,13 @@ const Brand = () => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const AppActions = () => (
|
const AppActions = () => (
|
||||||
<>
|
<div className="flex flex-col gap-0.5 w-full">
|
||||||
<SettingsModal />
|
<SettingsModal
|
||||||
<UserMenu />
|
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() {
|
export function HomeContent() {
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,13 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [
|
||||||
|
|
||||||
type AdminSubTab = 'providers' | 'features';
|
type AdminSubTab = 'providers' | 'features';
|
||||||
|
|
||||||
export function SettingsModal({ className = '' }: { className?: string }) {
|
export function SettingsModal({
|
||||||
|
className = '',
|
||||||
|
triggerLabel,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
triggerLabel?: string;
|
||||||
|
}) {
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
|
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
|
||||||
const showAllProviderModels = runtimeConfig.showAllProviderModels;
|
const showAllProviderModels = runtimeConfig.showAllProviderModels;
|
||||||
|
|
@ -506,6 +512,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" />
|
<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>
|
</Button>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,17 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { getAuthClient } from '@/lib/client/auth-client';
|
import { getAuthClient } from '@/lib/client/auth-client';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { authEnabled, baseUrl } = useAuthConfig();
|
||||||
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
||||||
const { data: session, isPending } = useAuthSession();
|
const { data: session, isPending } = useAuthSession();
|
||||||
|
|
@ -22,7 +31,27 @@ export function UserMenu({ className = '' }: { className?: string }) {
|
||||||
router.push('/signin');
|
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 (!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 (
|
return (
|
||||||
<div className={`flex gap-2 ${className}`}>
|
<div className={`flex gap-2 ${className}`}>
|
||||||
<Link href="/signin">
|
<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 (
|
return (
|
||||||
<div className={`flex items-center gap-2 px-2 py-1 rounded-md border border-offbase bg-base ${className}`}>
|
<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]">
|
<span className="hidden sm:block text-xs font-medium text-foreground truncate max-w-[160px]">
|
||||||
|
|
|
||||||
|
|
@ -502,7 +502,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
isSidebarOpen={effectiveSidebarOpen}
|
isSidebarOpen={effectiveSidebarOpen}
|
||||||
isNarrow={isNarrow}
|
isNarrow={isNarrow}
|
||||||
leftSlot={brand}
|
leftSlot={brand}
|
||||||
rightSlot={appActions}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sidebar={
|
sidebar={
|
||||||
|
|
@ -521,6 +520,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
onWidthChange={setSidebarWidth}
|
onWidthChange={setSidebarWidth}
|
||||||
topSlot={<DocumentUploader variant="compact" />}
|
topSlot={<DocumentUploader variant="compact" />}
|
||||||
|
bottomSlot={appActions}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
statusBar={
|
statusBar={
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ interface FinderSidebarProps {
|
||||||
width: number;
|
width: number;
|
||||||
onWidthChange: (px: number) => void;
|
onWidthChange: (px: number) => void;
|
||||||
topSlot?: ReactNode;
|
topSlot?: ReactNode;
|
||||||
|
bottomSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_WIDTH = 168;
|
const MIN_WIDTH = 168;
|
||||||
|
|
@ -180,6 +181,7 @@ export function FinderSidebar({
|
||||||
width,
|
width,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
topSlot,
|
topSlot,
|
||||||
|
bottomSlot,
|
||||||
}: FinderSidebarProps) {
|
}: FinderSidebarProps) {
|
||||||
const startRef = useRef<{ x: number; w: number } | null>(null);
|
const startRef = useRef<{ x: number; w: number } | null>(null);
|
||||||
|
|
||||||
|
|
@ -201,124 +203,131 @@ export function FinderSidebar({
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
|
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">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{topSlot && (
|
<div className="p-2 flex flex-col gap-0.5">
|
||||||
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
{topSlot && (
|
||||||
{topSlot}
|
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
</div>
|
{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>
|
|
||||||
)}
|
)}
|
||||||
>
|
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
|
||||||
Folders
|
<SidebarRow
|
||||||
</SectionHeader>
|
active={filter === 'all'}
|
||||||
{folders.length === 0 ? (
|
onClick={() => onFilterChange('all')}
|
||||||
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
|
icon={<HomeIcon className="w-3.5 h-3.5" />}
|
||||||
) : (
|
label="All Documents"
|
||||||
folders.map((folder) => (
|
count={counts.all}
|
||||||
<FolderRow
|
/>
|
||||||
key={folder.id}
|
<SidebarRow
|
||||||
folder={folder}
|
active={filter === 'recents'}
|
||||||
active={filter === `folder:${folder.id}`}
|
onClick={() => onFilterChange('recents')}
|
||||||
onClick={() => onFilterChange(`folder:${folder.id}`)}
|
icon={<ClockIcon className="w-3.5 h-3.5" />}
|
||||||
onDelete={() => onDeleteFolder(folder.id)}
|
label="Recently Opened"
|
||||||
onDropOnFolder={onDropOnFolder}
|
/>
|
||||||
/>
|
|
||||||
))
|
<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>
|
</div>
|
||||||
|
{bottomSlot && (
|
||||||
|
<div className="shrink-0 border-t border-offbase p-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{bottomSlot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue