🪄 refactor: UI Polish and Admin Dialog Unification (#11108)

* refactor(OpenSidebar): removed useless classNames

* style(Header): update hover styles across various components for improved UI consistency

* style(Nav): update hover styles in AccountSettings and SearchBar for improved UI consistency

* style: update button classes for consistent hover effects and improved UI responsiveness

* style(Nav, OpenSidebar, Header, Convo): improve UI responsiveness and animation transitions

* style(PresetsMenu, NewChat): update icon sizes and improve component styling for better UI consistency

* style(Nav, Root): enhance sidebar mobile animations and responsiveness for better UI experience

* style(ExportAndShareMenu, BookmarkMenu): update icon sizes for improved UI consistency

* style: remove transition duration from button classes for improved UI responsiveness

* style(CustomMenu, ModelSelector): update background colors for improved UI consistency and responsiveness

* style(ExportAndShareMenu): update icon color for improved UI consistency

* style(TemporaryChat): refine button styles for improved UI consistency and responsiveness

* style(BookmarkNav): refactor to use DropdownPopup and remove BookmarkNavItems for improved UI consistency and functionality

* style(CustomMenu, EndpointItem): enhance UI elements for improved consistency and accessibility

* style(EndpointItem): adjust gap in icon container for improved layout consistency

* style(CustomMenu, EndpointItem): update focus ring color for improved UI consistency

* style(EndpointItem): update icon color for improved UI consistency in dark theme

* style: update focus styles for improved accessibility and consistency across components

* refactor(Nav): extract sidebar width to NAV_WIDTH constant

Centralize mobile (320px) and desktop (260px) sidebar widths in a single
exported constant to avoid magic numbers and ensure consistency.

* fix(BookmarkNav): memoize handlers used in useMemo

Wrap handleTagClick and handleClear in useCallback and add them to the
dropdownItems useMemo dependency array to prevent stale closures.

* feat: introduce FilterInput component and replace existing inputs with it across multiple components

* feat(DataTable): replace custom input with FilterInput component for improved filtering

* fix: Nested dialog overlay stacking issue

Fixes overlay appearing behind content when opening nested dialogs.
Introduced dynamic z-index calculation based on dialog depth using React context.

- First dialog: overlay z-50, content z-100
- Nested dialogs increment by 60: overlay z-110/content z-160, etc.

Preserves a11y escape key handling from #10975 and #10851.

Regression from #11008 (afb67fcf1) which increased content z-index
without adjusting overlay z-index for nested dialog scenarios.

* Refactor admin settings components to use a unified AdminSettingsDialog

- Removed redundant code from AdminSettings, MCPAdminSettings, and Memories AdminSettings components.
- Introduced AdminSettingsDialog component to handle permission management for different sections.
- Updated permission handling logic to use a consistent structure across components.
- Enhanced role selection and permission confirmation features in the new dialog.
- Improved UI consistency and maintainability by centralizing dialog functionality.

* refactor(Memory): memory management UI components and replace MemoryViewer with MemoryPanel

* refactor(Memory): enhance UI components for Memory dialogs and improve input styling

* refactor(Bookmarks): improve bookmark management UI with enhanced styling

* refactor(translations): remove redundant filter input and bookmark count entries

* refactor(Convo): integrate useShiftKey hook for enhanced keyboard interaction and improve UI responsiveness
This commit is contained in:
Marco Beretta 2025-12-28 17:01:25 +01:00 committed by GitHub
parent c21733930c
commit 5181356bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 2115 additions and 2191 deletions

View file

@ -0,0 +1,111 @@
import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { GripVertical } from 'lucide-react';
import type { TConversationTag } from 'librechat-data-provider';
import { TooltipAnchor, useToastContext } from '@librechat/client';
import { useConversationTagMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import BookmarkCardActions from './BookmarkCardActions';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface BookmarkCardProps {
bookmark: TConversationTag;
position: number;
moveRow: (dragIndex: number, hoverIndex: number) => void;
}
interface DragItem {
index: number;
id: string;
type: string;
}
export default function BookmarkCard({ bookmark, position, moveRow }: BookmarkCardProps) {
const ref = useRef<HTMLDivElement>(null);
const localize = useLocalize();
const { showToast } = useToastContext();
const mutation = useConversationTagMutation({
context: 'BookmarkCard',
tag: bookmark.tag,
});
const handleDrop = (item: DragItem) => {
mutation.mutate(
{ ...bookmark, position: item.index },
{
onSuccess: () => {
showToast({
message: localize('com_ui_bookmarks_update_success'),
severity: NotificationSeverity.SUCCESS,
});
},
onError: () => {
showToast({
message: localize('com_ui_bookmarks_update_error'),
severity: NotificationSeverity.ERROR,
});
},
},
);
};
const [, drop] = useDrop({
accept: 'bookmark',
drop: handleDrop,
hover(item: DragItem) {
if (!ref.current || item.index === position) {
return;
}
moveRow(item.index, position);
item.index = position;
},
});
const [{ isDragging }, drag] = useDrag({
type: 'bookmark',
item: { index: position },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<div
ref={ref}
className={cn(
'flex cursor-move items-center gap-2 rounded-lg px-3 py-2.5',
'border border-border-light bg-transparent',
'hover:bg-surface-secondary',
isDragging && 'opacity-50',
)}
>
{/* Drag handle */}
<GripVertical className="size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
{/* Tag name */}
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-text-primary">
{bookmark.tag}
</span>
{/* Count badge */}
<TooltipAnchor
description={`${bookmark.count} ${localize(bookmark.count === 1 ? 'com_ui_conversation' : 'com_ui_conversations')}`}
side="top"
render={
<span className="shrink-0 rounded-full bg-surface-tertiary px-2 py-0.5 text-xs text-text-secondary">
{bookmark.count}
</span>
}
/>
{/* Actions */}
<div className="shrink-0">
<BookmarkCardActions bookmark={bookmark} />
</div>
</div>
);
}

View file

@ -0,0 +1,118 @@
import { useState, useRef, useCallback } from 'react';
import { Pencil, Trash2 } from 'lucide-react';
import type { TConversationTag } from 'librechat-data-provider';
import {
Button,
OGDialog,
OGDialogTrigger,
OGDialogTemplate,
TooltipAnchor,
useToastContext,
} from '@librechat/client';
import { useDeleteConversationTagMutation } from '~/data-provider';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
interface BookmarkCardActionsProps {
bookmark: TConversationTag;
}
export default function BookmarkCardActions({ bookmark }: BookmarkCardActionsProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const editTriggerRef = useRef<HTMLButtonElement>(null);
const deleteTriggerRef = useRef<HTMLButtonElement>(null);
const deleteBookmarkMutation = useDeleteConversationTagMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_bookmarks_delete_success'),
});
setDeleteOpen(false);
},
onError: () => {
showToast({
message: localize('com_ui_bookmarks_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const confirmDelete = useCallback(async () => {
await deleteBookmarkMutation.mutateAsync(bookmark.tag);
}, [bookmark.tag, deleteBookmarkMutation]);
return (
<div className="flex items-center gap-1">
{/* Edit button */}
<BookmarkEditDialog
context="BookmarkCardActions"
bookmark={bookmark}
open={editOpen}
setOpen={setEditOpen}
triggerRef={editTriggerRef}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit')}
side="top"
render={
<Button
ref={editTriggerRef}
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setEditOpen(true)}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
</BookmarkEditDialog>
{/* Delete button */}
<OGDialog open={deleteOpen} onOpenChange={setDeleteOpen} triggerRef={deleteTriggerRef}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
side="top"
render={
<Button
ref={deleteTriggerRef}
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_bookmarks_delete')}
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_bookmarks_delete')}
className="max-w-[450px]"
main={
<p className="text-left text-sm text-text-secondary">
{localize('com_ui_bookmark_delete_confirm')} <strong>{bookmark.tag}</strong>
</p>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { Bookmark } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface BookmarkEmptyStateProps {
isFiltered?: boolean;
}
export default function BookmarkEmptyState({ isFiltered = false }: BookmarkEmptyStateProps) {
const localize = useLocalize();
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="mb-3 rounded-full bg-surface-secondary p-3">
<Bookmark className="size-6 text-text-tertiary" aria-hidden="true" />
</div>
<p className="text-sm text-text-secondary">
{isFiltered ? localize('com_ui_no_bookmarks_match') : localize('com_ui_no_bookmarks')}
</p>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { TConversationTag } from 'librechat-data-provider';
import BookmarkEmptyState from './BookmarkEmptyState';
import BookmarkCard from './BookmarkCard';
import { useLocalize } from '~/hooks';
interface BookmarkListProps {
bookmarks: TConversationTag[];
moveRow: (dragIndex: number, hoverIndex: number) => void;
isFiltered?: boolean;
}
export default function BookmarkList({
bookmarks,
moveRow,
isFiltered = false,
}: BookmarkListProps) {
const localize = useLocalize();
if (bookmarks.length === 0) {
return <BookmarkEmptyState isFiltered={isFiltered} />;
}
return (
<div className="space-y-2" role="list" aria-label={localize('com_ui_bookmarks')}>
{bookmarks.map((bookmark) => (
<div key={bookmark._id} role="listitem">
<BookmarkCard bookmark={bookmark} position={bookmark.position} moveRow={moveRow} />
</div>
))}
</div>
);
}

View file

@ -1,22 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react';
import { BookmarkPlusIcon } from 'lucide-react';
import {
Table,
Input,
Button,
TableRow,
TableHead,
TableBody,
TableCell,
TableHeader,
OGDialogTrigger,
} from '@librechat/client';
import { Plus } from 'lucide-react';
import { Button, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client';
import type { ConversationTagsResponse, TConversationTag } from 'librechat-data-provider';
import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import BookmarkTableRow from './BookmarkTableRow';
import BookmarkList from './BookmarkList';
import { useLocalize } from '~/hooks';
const pageSize = 10;
const removeDuplicates = (bookmarks: TConversationTag[]) => {
const seen = new Set();
return bookmarks.filter((bookmark) => {
@ -31,8 +23,7 @@ const BookmarkTable = () => {
const [rows, setRows] = useState<ConversationTagsResponse>([]);
const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
const pageSize = 10;
const [createOpen, setCreateOpen] = useState(false);
const { bookmarks = [] } = useBookmarkContext();
@ -41,6 +32,11 @@ const BookmarkTable = () => {
setRows(_bookmarks);
}, [bookmarks]);
// Reset page when search changes
useEffect(() => {
setPageIndex(0);
}, [searchQuery]);
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
setRows((prevTags: TConversationTag[]) => {
const updatedRows = [...prevTags];
@ -50,86 +46,60 @@ const BookmarkTable = () => {
});
}, []);
const renderRow = useCallback(
(row: TConversationTag) => (
<BookmarkTableRow key={row._id} moveRow={moveRow} row={row} position={row.position} />
),
[moveRow],
);
const filteredRows = rows.filter(
(row) => row.tag && row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
);
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
const totalPages = Math.ceil(filteredRows.length / pageSize);
return (
<BookmarkContext.Provider value={{ bookmarks }}>
<div role="region" aria-label={localize('com_ui_bookmarks')} className="mt-2 space-y-2">
<div className="relative flex items-center gap-4">
<Input
id="bookmarks-filter"
placeholder=" "
<div role="region" aria-label={localize('com_ui_bookmarks')} className="mt-2 space-y-3">
{/* Header: Filter + Create Button */}
<div className="flex items-center gap-2">
<FilterInput
inputId="bookmarks-filter"
label={localize('com_ui_bookmarks_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={localize('com_ui_bookmarks_filter')}
className="peer"
containerClassName="flex-1"
/>
<label
htmlFor="bookmarks-filter"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
<BookmarkEditDialog context="BookmarkTable" open={createOpen} setOpen={setCreateOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_bookmarks_new')}
side="bottom"
render={
<Button
variant="outline"
size="icon"
className="shrink-0 bg-transparent"
aria-label={localize('com_ui_bookmarks_new')}
onClick={() => setCreateOpen(true)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
</BookmarkEditDialog>
</div>
{/* Bookmark List */}
<BookmarkList
bookmarks={currentRows}
moveRow={moveRow}
isFiltered={searchQuery.length > 0}
/>
{/* Pagination */}
{filteredRows.length > pageSize && (
<div
className="flex items-center justify-end gap-2"
role="navigation"
aria-label="Pagination"
>
{localize('com_ui_bookmarks_filter')}
</label>
</div>
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
<Table className="w-full table-fixed">
<TableHeader>
<TableRow className="border-b border-border-light">
<TableHead className="w-[70%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
<div>{localize('com_ui_bookmarks_title')}</div>
</TableHead>
<TableHead className="w-[30%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
<div>{localize('com_ui_bookmarks_count')}</div>
</TableHead>
<TableHead className="w-[40%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
<div>{localize('com_assistants_actions')}</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentRows.length ? (
currentRows.map(renderRow)
) : (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center text-sm text-text-secondary">
{localize('com_ui_no_bookmarks')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="flex justify-between gap-2">
<BookmarkEditDialog context="BookmarkPanel" open={open} setOpen={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full gap-2 text-sm"
aria-label={localize('com_ui_bookmarks_new')}
onClick={() => setOpen(!open)}
>
<BookmarkPlusIcon className="size-4" aria-hidden="true" />
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
</Button>
</OGDialogTrigger>
</BookmarkEditDialog>
</div>
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
<Button
variant="outline"
size="sm"
@ -139,24 +109,20 @@ const BookmarkTable = () => {
>
{localize('com_ui_prev')}
</Button>
<div aria-live="polite" className="text-sm">
{`${pageIndex + 1} / ${Math.ceil(filteredRows.length / pageSize)}`}
<div className="whitespace-nowrap text-sm" aria-live="polite">
{pageIndex + 1} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
onClick={() => setPageIndex((prev) => (prev + 1 < totalPages ? prev + 1 : prev))}
disabled={pageIndex + 1 >= totalPages}
aria-label={localize('com_ui_next')}
>
{localize('com_ui_next')}
</Button>
</div>
</div>
)}
</div>
</BookmarkContext.Provider>
);

View file

@ -1,90 +0,0 @@
import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import type { TConversationTag } from 'librechat-data-provider';
import { TableRow, TableCell, useToastContext } from '@librechat/client';
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
import { useConversationTagMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
interface BookmarkTableRowProps {
row: TConversationTag;
moveRow: (dragIndex: number, hoverIndex: number) => void;
position: number;
}
interface DragItem {
index: number;
id: string;
type: string;
}
const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, position }) => {
const ref = useRef<HTMLTableRowElement>(null);
const mutation = useConversationTagMutation({ context: 'BookmarkTableRow', tag: row.tag });
const localize = useLocalize();
const { showToast } = useToastContext();
const handleDrop = (item: DragItem) => {
mutation.mutate(
{ ...row, position: item.index },
{
onSuccess: () => {
showToast({
message: localize('com_ui_bookmarks_update_success'),
severity: NotificationSeverity.SUCCESS,
});
},
onError: () => {
showToast({
message: localize('com_ui_bookmarks_update_error'),
severity: NotificationSeverity.ERROR,
});
},
},
);
};
const [, drop] = useDrop({
accept: 'bookmark',
drop: handleDrop,
hover(item: DragItem) {
if (!ref.current || item.index === position) {
return;
}
moveRow(item.index, position);
item.index = position;
},
});
const [{ isDragging }, drag] = useDrag({
type: 'bookmark',
item: { index: position },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<TableRow
ref={ref}
className="cursor-move hover:bg-surface-secondary"
style={{ opacity: isDragging ? 0.5 : 1 }}
>
<TableCell className="w-[70%] px-4 py-4">
<div className="overflow-hidden text-ellipsis whitespace-nowrap">{row.tag}</div>
</TableCell>
<TableCell className="w-[10%] px-12 py-4">{row.count}</TableCell>
<TableCell className="w-[20%] px-4 py-4">
<div className="flex gap-2">
<EditBookmarkButton bookmark={row} tabIndex={0} />
<DeleteBookmarkButton bookmark={row.tag} tabIndex={0} />
</div>
</TableCell>
</TableRow>
);
};
export default BookmarkTableRow;

View file

@ -0,0 +1,6 @@
export { default as BookmarkPanel } from './BookmarkPanel';
export { default as BookmarkTable } from './BookmarkTable';
export { default as BookmarkList } from './BookmarkList';
export { default as BookmarkCard } from './BookmarkCard';
export { default as BookmarkCardActions } from './BookmarkCardActions';
export { default as BookmarkEmptyState } from './BookmarkEmptyState';