diff --git a/.gitignore b/.gitignore index c4931cd..5aa1bff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # Logs data-node meili_data* -data/ +/data/ logs *.log diff --git a/client/src/components/Library/LibraryEmptyState.tsx b/client/src/components/Library/LibraryEmptyState.tsx new file mode 100644 index 0000000..6937b23 --- /dev/null +++ b/client/src/components/Library/LibraryEmptyState.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { FolderOpen, SearchX } from 'lucide-react'; +import { useLocalize } from '~/hooks'; + +interface LibraryEmptyStateProps { + /** True when filters/search produced no matches but the library has items. */ + isFiltered?: boolean; + onClearFilters?: () => void; +} + +interface EmptyStateLayoutProps { + icon: LucideIcon; + title: string; + description: string; + children?: ReactNode; +} + +function EmptyStateLayout({ icon: Icon, title, description, children }: EmptyStateLayoutProps) { + return ( +
+
+
+

{title}

+

{description}

+ {children} +
+ ); +} + +export default function LibraryEmptyState({ + isFiltered = false, + onClearFilters, +}: LibraryEmptyStateProps) { + const localize = useLocalize(); + + if (isFiltered) { + return ( + + {onClearFilters && ( + + )} + + ); + } + + return ( + + ); +} diff --git a/client/src/components/Library/LibraryErrorState.tsx b/client/src/components/Library/LibraryErrorState.tsx new file mode 100644 index 0000000..189c381 --- /dev/null +++ b/client/src/components/Library/LibraryErrorState.tsx @@ -0,0 +1,41 @@ +import { AlertCircle } from 'lucide-react'; +import { Button } from '@librechat/client'; +import { useLocalize } from '~/hooks'; + +interface LibraryErrorStateProps { + message?: string; + onRetry?: () => void; +} + +export default function LibraryErrorState({ message, onRetry }: LibraryErrorStateProps) { + const localize = useLocalize(); + const displayMessage = message ?? localize('com_ui_library_error_default'); + + return ( +
+
+
+

+ {localize('com_ui_library_error_title')} +

+

{displayMessage}

+ {onRetry && ( + + )} +
+ ); +} diff --git a/client/src/components/Library/LibraryFilters.tsx b/client/src/components/Library/LibraryFilters.tsx new file mode 100644 index 0000000..bceee45 --- /dev/null +++ b/client/src/components/Library/LibraryFilters.tsx @@ -0,0 +1,156 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; +import { Search } from 'lucide-react'; +import { CrossIcon } from '@librechat/client'; +import { Input, SelectDropDown } from '@librechat/client'; +import type { LibraryFiltersState, LibraryViewMode } from '~/types/library'; +import { useDebounce, useLocalize } from '~/hooks'; +import { createDropdownSetter } from '~/utils'; +import { + findLibraryOption, + createLibrarySortOptions, + createLibraryTypeFilterOptions, + parseLibrarySortOption, + parseLibraryTypeFilter, +} from './libraryUtils'; +import LibraryViewToggle from './LibraryViewToggle'; + +interface LibraryFiltersProps { + filters: LibraryFiltersState; + onFiltersChange: Dispatch>; + resultCount: number; + totalCount: number; + viewMode: LibraryViewMode; + onViewModeChange: (mode: LibraryViewMode) => void; +} + +export default function LibraryFilters({ + filters, + onFiltersChange, + resultCount, + totalCount, + viewMode, + onViewModeChange, +}: LibraryFiltersProps) { + const localize = useLocalize(); + const [searchInput, setSearchInput] = useState(filters.search); + const debouncedSearch = useDebounce(searchInput, 300); + + useEffect(() => { + setSearchInput(filters.search); + }, [filters.search]); + + useEffect(() => { + if (debouncedSearch !== filters.search && debouncedSearch === searchInput) { + onFiltersChange((prev) => ({ ...prev, search: debouncedSearch })); + } + }, [debouncedSearch, filters.search, onFiltersChange, searchInput]); + + const handleClearSearch = useCallback(() => { + setSearchInput(''); + onFiltersChange((prev) => ({ ...prev, search: '' })); + }, [onFiltersChange]); + + const typeFilterOptions = useMemo(() => createLibraryTypeFilterOptions(localize), [localize]); + const sortOptions = useMemo(() => createLibrarySortOptions(localize), [localize]); + + const selectedTypeOption = findLibraryOption(typeFilterOptions, filters.typeFilter); + + const selectedSortOption = findLibraryOption(sortOptions, filters.sortBy); + + const handleTypeChange = useMemo( + () => + createDropdownSetter((value) => { + const typeFilter = parseLibraryTypeFilter(value); + onFiltersChange((prev) => + prev.typeFilter === typeFilter ? prev : { ...prev, typeFilter }, + ); + }), + [onFiltersChange], + ); + + const handleSortChange = useMemo( + () => + createDropdownSetter((value) => { + const sortBy = parseLibrarySortOption(value); + onFiltersChange((prev) => (prev.sortBy === sortBy ? prev : { ...prev, sortBy })); + }), + [onFiltersChange], + ); + + return ( +
+
+
+ + setSearchInput(event.target.value)} + placeholder={localize('com_ui_library_search_placeholder')} + className="h-10 rounded-lg border-border-medium bg-transparent pl-10 pr-10 text-sm text-text-primary placeholder:text-text-secondary focus:ring-0" + aria-label={localize('com_ui_library_search')} + autoComplete="off" + spellCheck={false} + /> +
+ +
+
+ +
+
+ +
+ +
+
+ +

+ {localize( + totalCount === 1 ? 'com_ui_library_items_count_one' : 'com_ui_library_items_count_other', + { resultCount, totalCount }, + )} +

+
+ ); +} diff --git a/client/src/components/Library/LibraryItemActions.tsx b/client/src/components/Library/LibraryItemActions.tsx new file mode 100644 index 0000000..f2f8298 --- /dev/null +++ b/client/src/components/Library/LibraryItemActions.tsx @@ -0,0 +1,75 @@ +import { Download, ExternalLink, MoreVertical, Share2 } from 'lucide-react'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +interface LibraryItemActionsProps { + onOpen?: () => void; + onDownload?: () => void; + onShare?: () => void; + onMore?: () => void; + className?: string; +} + +export default function LibraryItemActions({ + onOpen, + onDownload, + onShare, + onMore, + className, +}: LibraryItemActionsProps) { + const localize = useLocalize(); + + if (!onOpen && !onDownload && !onShare && !onMore) { + return null; + } + + return ( +
event.stopPropagation()} + role="group" + aria-label={localize('com_ui_library_quick_actions')} + > + {onOpen && ( + + )} + {onDownload && ( + + )} + {onShare && ( + + )} + {onMore && ( + + )} +
+ ); +} diff --git a/client/src/components/Library/LibraryList.tsx b/client/src/components/Library/LibraryList.tsx new file mode 100644 index 0000000..402c609 --- /dev/null +++ b/client/src/components/Library/LibraryList.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { + LibraryFiltersState, + LibraryItem, + LibraryListStatus, + LibraryStorageInfo, + LibraryViewMode, +} from '~/types/library'; +import { useLocalize } from '~/hooks'; +import LibraryEmptyState from './LibraryEmptyState'; +import LibraryErrorState from './LibraryErrorState'; +import LibraryFilters from './LibraryFilters'; +import LibraryListItem from './LibraryListItem'; +import LibraryLoadingState from './LibraryLoadingState'; +import LibraryPreviewCard from './LibraryPreviewCard'; +import LibraryStorageBar from './LibraryStorageBar'; +import { applyLibraryFilters } from './libraryUtils'; + +const DEFAULT_FILTERS: LibraryFiltersState = { + search: '', + typeFilter: 'all', + sortBy: 'newest', +}; + +interface LibraryListProps { + items: LibraryItem[]; + status: LibraryListStatus; + storage?: LibraryStorageInfo | null; + errorMessage?: string; + onRetry?: () => void; + onItemClick?: (item: LibraryItem) => void; + onDownload?: (item: LibraryItem) => void; + onShare?: (item: LibraryItem) => void; + onMore?: (item: LibraryItem) => void; + className?: string; +} + +export default function LibraryList({ + items, + status, + storage = null, + errorMessage, + onRetry, + onItemClick, + onDownload, + onShare, + onMore, + className = '', +}: LibraryListProps) { + const localize = useLocalize(); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [viewMode, setViewMode] = useState('dense'); + + const filteredItems = useMemo( + () => (status === 'populated' ? applyLibraryFilters(items, filters) : []), + [items, filters, status], + ); + + const isFiltered = filters.search.trim().length > 0 || filters.typeFilter !== 'all'; + + const handleClearFilters = useCallback(() => { + setFilters(DEFAULT_FILTERS); + }, []); + + if (status === 'loading') { + return ( +
+ +
+ ); + } + + if (status === 'error') { + return ( +
+ +
+ ); + } + + if (status === 'empty') { + return ( +
+ {storage && } +
+ +
+
+ ); + } + + let listContent: React.ReactNode; + if (filteredItems.length === 0) { + listContent = ; + } else if (viewMode === 'preview') { + listContent = ( +
+ {filteredItems.map((item) => ( + + ))} +
+ ); + } else { + listContent = ( +
+ {filteredItems.map((item) => ( + + ))} +
+ ); + } + + return ( +
+ {storage && } + + + + {listContent} +
+ ); +} diff --git a/client/src/components/Library/LibraryListItem.tsx b/client/src/components/Library/LibraryListItem.tsx new file mode 100644 index 0000000..fd61e02 --- /dev/null +++ b/client/src/components/Library/LibraryListItem.tsx @@ -0,0 +1,103 @@ +import { FileIcon, Sparkles } from '@librechat/client'; +import type { LibraryItem } from '~/types/library'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import { formatLibraryDate, formatLibraryFileSize, getLibraryTypeLabel } from './libraryUtils'; +import { getLibraryFileType, TYPE_COLOR_MAP } from './libraryTypeMeta'; +import LibraryItemActions from './LibraryItemActions'; + +interface LibraryListItemProps { + item: LibraryItem; + onClick?: (item: LibraryItem) => void; + onDownload?: (item: LibraryItem) => void; + onShare?: (item: LibraryItem) => void; + onMore?: (item: LibraryItem) => void; +} + +const itemRowClassName = + 'flex w-full items-center gap-3 rounded-lg border border-border-light bg-surface-primary px-4 py-3 text-left transition-colors'; + +export default function LibraryListItem({ + item, + onClick, + onDownload, + onShare, + onMore, +}: LibraryListItemProps) { + const localize = useLocalize(); + const fileType = getLibraryFileType(item.type, localize); + const isArtifact = item.type === 'artifact'; + const iconColor = TYPE_COLOR_MAP[item.type]; + const typeLabel = getLibraryTypeLabel(item.type, localize); + const itemKind = localize( + item.kind === 'artifact' ? 'com_ui_library_type_artifact' : 'com_ui_library_type_file', + ); + const ariaLabel = localize('com_ui_library_item_label', { + name: item.name, + type: typeLabel, + size: formatLibraryFileSize(item.sizeBytes), + }); + + const content = ( + <> + + +
+
+

{item.name}

+ + {formatLibraryFileSize(item.sizeBytes)} + +
+
+ {typeLabel} + {itemKind} + + +
+ {item.description && ( +

{item.description}

+ )} +
+ + ); + + return ( +
+ {onClick ? ( + + ) : ( +
{content}
+ )} + onClick(item) : undefined} + onDownload={onDownload ? () => onDownload(item) : undefined} + onShare={onShare ? () => onShare(item) : undefined} + onMore={onMore ? () => onMore(item) : undefined} + className="hidden shrink-0 sm:flex" + /> +
+ ); +} diff --git a/client/src/components/Library/LibraryListWithMockData.tsx b/client/src/components/Library/LibraryListWithMockData.tsx new file mode 100644 index 0000000..09e17b5 --- /dev/null +++ b/client/src/components/Library/LibraryListWithMockData.tsx @@ -0,0 +1,29 @@ +import LibraryList from './LibraryList'; +import { useMockLibraryData, type MockLibraryScenario } from './useMockLibraryData'; + +interface LibraryListWithMockDataProps { + scenario?: MockLibraryScenario; + className?: string; +} + +/** + * Convenience wrapper that wires LibraryList to the mock data hook. + * Drop-in component for development until the real API is integrated. + */ +export default function LibraryListWithMockData({ + scenario = 'success', + className, +}: LibraryListWithMockDataProps) { + const { items, storage, status, errorMessage, refetch } = useMockLibraryData({ scenario }); + + return ( + + ); +} diff --git a/client/src/components/Library/LibraryLoadingState.tsx b/client/src/components/Library/LibraryLoadingState.tsx new file mode 100644 index 0000000..248ddb5 --- /dev/null +++ b/client/src/components/Library/LibraryLoadingState.tsx @@ -0,0 +1,31 @@ +import { Skeleton } from '@librechat/client'; +import { useLocalize } from '~/hooks'; + +const SKELETON_ROWS = 6; + +export default function LibraryLoadingState() { + const localize = useLocalize(); + + return ( +
+
+ +
+ + +
+
+ +
+ {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/Library/LibraryPreviewCard.tsx b/client/src/components/Library/LibraryPreviewCard.tsx new file mode 100644 index 0000000..3d20261 --- /dev/null +++ b/client/src/components/Library/LibraryPreviewCard.tsx @@ -0,0 +1,235 @@ +import { FileIcon, Sparkles } from '@librechat/client'; +import type { LocalizeFunction } from '~/common'; +import type { LibraryItem } from '~/types/library'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import { formatLibraryDate, formatLibraryFileSize, getLibraryTypeLabel } from './libraryUtils'; +import { getLibraryFileType, TYPE_COLOR_MAP } from './libraryTypeMeta'; +import LibraryItemActions from './LibraryItemActions'; + +interface LibraryPreviewCardProps { + item: LibraryItem; + onClick?: (item: LibraryItem) => void; + onDownload?: (item: LibraryItem) => void; + onShare?: (item: LibraryItem) => void; + onMore?: (item: LibraryItem) => void; +} + +function isPresentation(item: LibraryItem): boolean { + return ( + item.name.toLowerCase().endsWith('.pptx') || item.contentType?.includes('presentation') === true + ); +} + +function isWordOrMarkdown(item: LibraryItem): boolean { + const lower = item.name.toLowerCase(); + return ( + lower.endsWith('.docx') || + lower.endsWith('.md') || + item.contentType?.includes('wordprocessing') === true + ); +} + +function DeckPreview() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function DocumentPreview() { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +function SpreadsheetPreview() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ {Array.from({ length: 4 }).map((_, row) => ( +
+ {Array.from({ length: 4 }).map((_, col) => ( +
+ ))} +
+ ))} +
+ ); +} + +function CodePreview() { + const bars = [ + 'w-3/4 bg-emerald-600', + 'w-1/2 bg-blue-500', + 'w-5/6 bg-amber-500', + 'w-2/3 bg-emerald-600', + 'w-3/4 bg-blue-500', + ]; + return ( +
+ {bars.map((bar, i) => ( +
+ ))} +
+ ); +} + +function PreviewArea({ item, localize }: { item: LibraryItem; localize: LocalizeFunction }) { + if (item.type === 'pdf' || isWordOrMarkdown(item)) { + return ; + } + if (item.type === 'spreadsheet') { + return ; + } + if (item.type === 'code' || item.contentType?.includes('html')) { + return ; + } + if (isPresentation(item)) { + return ; + } + + const fileType = getLibraryFileType(item.type, localize); + const iconColor = TYPE_COLOR_MAP[item.type]; + const isArtifact = item.type === 'artifact'; + + return ( + + ); +} + +export default function LibraryPreviewCard({ + item, + onClick, + onDownload, + onShare, + onMore, +}: LibraryPreviewCardProps) { + const localize = useLocalize(); + const hasActions = Boolean(onClick || onDownload || onShare || onMore); + const fileType = getLibraryFileType(item.type, localize); + const isArtifact = item.type === 'artifact'; + const iconColor = TYPE_COLOR_MAP[item.type]; + const typeLabel = getLibraryTypeLabel(item.type, localize); + const itemKind = localize( + item.kind === 'artifact' ? 'com_ui_library_type_artifact' : 'com_ui_library_type_file', + ); + const ariaLabel = localize('com_ui_library_item_label', { + name: item.name, + type: typeLabel, + size: formatLibraryFileSize(item.sizeBytes), + }); + + const sourceLabel = item.conversationId + ? localize('com_ui_library_chat_source', { chatId: item.conversationId }) + : itemKind; + + return ( +
+ {onClick ? ( + + ) : ( +
+ +
+ )} + +
+
+ +

+ {item.name} +

+
+ +
+ + {typeLabel} + + {sourceLabel} +
+ +
+ + + {formatLibraryFileSize(item.sizeBytes)} +
+ + {hasActions && ( +
+ + {localize('com_ui_library_quick_actions')} + + onClick(item) : undefined} + onDownload={onDownload ? () => onDownload(item) : undefined} + onShare={onShare ? () => onShare(item) : undefined} + onMore={onMore ? () => onMore(item) : undefined} + /> +
+ )} +
+
+ ); +} diff --git a/client/src/components/Library/LibraryStorageBar.tsx b/client/src/components/Library/LibraryStorageBar.tsx new file mode 100644 index 0000000..9c0ffae --- /dev/null +++ b/client/src/components/Library/LibraryStorageBar.tsx @@ -0,0 +1,32 @@ +import { Progress } from '@librechat/client'; +import type { LibraryStorageInfo } from '~/types/library'; +import { useLocalize } from '~/hooks'; +import { formatLibraryFileSize, getStorageUsagePercent } from './libraryUtils'; + +interface LibraryStorageBarProps { + storage: LibraryStorageInfo; +} + +export default function LibraryStorageBar({ storage }: LibraryStorageBarProps) { + const localize = useLocalize(); + const percent = getStorageUsagePercent(storage.usedBytes, storage.totalBytes); + const used = formatLibraryFileSize(storage.usedBytes); + const total = formatLibraryFileSize(storage.totalBytes); + + return ( +
+
+ {localize('com_ui_library_storage')} + + {localize('com_ui_library_storage_usage', { used, total })} + +
+ +

{percent}%

+
+ ); +} diff --git a/client/src/components/Library/LibraryViewToggle.tsx b/client/src/components/Library/LibraryViewToggle.tsx new file mode 100644 index 0000000..ea5a905 --- /dev/null +++ b/client/src/components/Library/LibraryViewToggle.tsx @@ -0,0 +1,58 @@ +import { LayoutGrid, List } from 'lucide-react'; +import type { LibraryViewMode } from '~/types/library'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +interface LibraryViewToggleProps { + viewMode: LibraryViewMode; + onViewModeChange: (mode: LibraryViewMode) => void; + className?: string; +} + +export default function LibraryViewToggle({ + viewMode, + onViewModeChange, + className, +}: LibraryViewToggleProps) { + const localize = useLocalize(); + + return ( +
+ + +
+ ); +} diff --git a/client/src/components/Library/__tests__/LibraryList.test.tsx b/client/src/components/Library/__tests__/LibraryList.test.tsx new file mode 100644 index 0000000..ce5e11e --- /dev/null +++ b/client/src/components/Library/__tests__/LibraryList.test.tsx @@ -0,0 +1,171 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { mockLibraryItems, mockLibraryStorage } from '~/data/mockLibraryData'; +import LibraryList from '../LibraryList'; + +jest.mock('~/hooks', () => ({ + useDebounce: (value: string) => value, + useLocalize: + () => + (key: string, options: Record = {}) => { + const translations = jest.requireActual('~/locales/en/translation.json') as Record< + string, + string + >; + return Object.entries(options).reduce( + (text, [name, value]) => text.replace(`{{${name}}}`, String(value)), + translations[key], + ); + }, +})); + +describe('LibraryList', () => { + it('renders loading state', () => { + render(); + expect(screen.getByRole('status', { name: 'Loading library' })).toBeInTheDocument(); + }); + + it('renders error state with retry', () => { + const onRetry = jest.fn(); + render( + , + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Network error')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + + it('renders empty state', () => { + render(); + expect(screen.getByText('Your library is empty')).toBeInTheDocument(); + expect(screen.getByText(/64\.2 MB of 1\.0 GB used/)).toBeInTheDocument(); + }); + + it('renders populated list with items', () => { + render( + , + ); + + expect(screen.getByRole('search')).toBeInTheDocument(); + expect( + screen.getByText(`Showing ${mockLibraryItems.length} of ${mockLibraryItems.length} items`), + ).toBeInTheDocument(); + expect(screen.getByText('Q4 Financial Report.pdf')).toBeInTheDocument(); + }); + + it('filters items by search query', async () => { + render( + , + ); + + const searchInput = screen.getByRole('searchbox', { name: 'Search library' }); + await userEvent.type(searchInput, 'architecture'); + + expect(screen.getByText('architecture-diagram.pdf')).toBeInTheDocument(); + expect(screen.queryByText('Q4 Financial Report.pdf')).not.toBeInTheDocument(); + expect(screen.getByText(/Showing 1 of \d+ items/)).toBeInTheDocument(); + }); + + it('filters items by type', async () => { + render( + , + ); + + const typeDropdown = screen.getByRole('button', { name: /Filter by type/i }); + await userEvent.click(typeDropdown); + + const pdfOption = await screen.findByRole('option', { name: 'PDF' }); + await userEvent.click(pdfOption); + + expect(screen.getByText('Q4 Financial Report.pdf')).toBeInTheDocument(); + expect(screen.getByText('architecture-diagram.pdf')).toBeInTheDocument(); + expect(screen.queryByText('product-mockup-v3.png')).not.toBeInTheDocument(); + }); + + it('sorts items by name', async () => { + render( + , + ); + + const sortDropdown = screen.getByRole('button', { name: /Sort library items/i }); + await userEvent.click(sortDropdown); + + const nameOption = await screen.findByRole('option', { name: 'Name (A–Z)' }); + await userEvent.click(nameOption); + + const list = screen.getByRole('list', { name: 'Library items' }); + const firstItem = within(list).getAllByRole('listitem')[0]; + expect(firstItem).toHaveTextContent('api-client.ts'); + }); + + it('toggles between dense and preview views', async () => { + render( + , + ); + + const list = screen.getByRole('list', { name: 'Library items' }); + expect(list).toHaveClass('flex-col'); + expect(screen.getByRole('group', { name: 'Library view' })).toBeInTheDocument(); + + const previewButton = screen.getByRole('button', { name: 'Preview grid view' }); + await userEvent.click(previewButton); + + const grid = screen.getByRole('list', { name: 'Library items' }); + expect(grid).toHaveClass('grid'); + expect(screen.getByText('Q4 Financial Report.pdf')).toBeInTheDocument(); + const firstCard = within(grid).getAllByRole('listitem')[0]; + expect(within(firstCard).queryByRole('button')).not.toBeInTheDocument(); + expect(within(firstCard).queryByText('Quick actions')).not.toBeInTheDocument(); + + const denseButton = screen.getByRole('button', { name: 'Dense list view' }); + await userEvent.click(denseButton); + + expect(screen.getByRole('list', { name: 'Library items' })).toHaveClass('flex-col'); + }); + + it('does not render quick actions without callbacks', () => { + render( + , + ); + + expect(screen.queryByRole('group', { name: 'Quick actions' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'More options' })).not.toBeInTheDocument(); + expect(screen.queryByText('Quick actions')).not.toBeInTheDocument(); + }); + + it('renders and invokes only configured quick actions', async () => { + const onItemClick = jest.fn(); + const onDownload = jest.fn(); + const onShare = jest.fn(); + const onMore = jest.fn(); + const item = mockLibraryItems[0]; + + render( + , + ); + + const quickActions = screen.getByRole('group', { name: 'Quick actions' }); + expect(screen.getByText('Showing 1 of 1 item')).toBeInTheDocument(); + await userEvent.click(within(quickActions).getByRole('button', { name: 'Open' })); + await userEvent.click(within(quickActions).getByRole('button', { name: 'Download' })); + await userEvent.click(within(quickActions).getByRole('button', { name: 'Share' })); + await userEvent.click(within(quickActions).getByRole('button', { name: 'More options' })); + + expect(onItemClick).toHaveBeenCalledWith(item); + expect(onDownload).toHaveBeenCalledWith(item); + expect(onShare).toHaveBeenCalledWith(item); + expect(onMore).toHaveBeenCalledWith(item); + }); +}); diff --git a/client/src/components/Library/__tests__/libraryUtils.test.ts b/client/src/components/Library/__tests__/libraryUtils.test.ts new file mode 100644 index 0000000..734c474 --- /dev/null +++ b/client/src/components/Library/__tests__/libraryUtils.test.ts @@ -0,0 +1,153 @@ +import { isSupportedArtifactContentType } from 'librechat-data-provider'; +import type { LocalizeFunction } from '~/common'; +import { mockLibraryItems } from '~/data/mockLibraryData'; +import english from '~/locales/en/translation.json'; +import { + applyLibraryFilters, + createLibrarySortOptions, + createLibraryTypeFilterOptions, + filterLibraryItemsBySearch, + filterLibraryItemsByType, + findLibraryOption, + formatLibraryFileSize, + getStorageUsagePercent, + parseLibrarySortOption, + parseLibraryTypeFilter, + sortLibraryItems, +} from '../libraryUtils'; + +const localize = ((key) => english[key]) as LocalizeFunction; + +describe('libraryUtils', () => { + it('keeps mock records aligned with shared file and artifact identifiers', () => { + for (const item of mockLibraryItems) { + expect(item.id).toMatch(item.kind === 'file' ? /^file_/ : /^artifact_/); + expect(item).not.toHaveProperty('mimeType'); + + const contentType = (item as { contentType?: string }).contentType; + if (contentType) { + expect(isSupportedArtifactContentType(contentType)).toBe(true); + } + } + }); + + describe('formatLibraryFileSize', () => { + it('formats bytes', () => { + expect(formatLibraryFileSize(512)).toBe('512 B'); + }); + + it('formats kilobytes', () => { + expect(formatLibraryFileSize(2048)).toBe('2.0 KB'); + }); + + it('formats megabytes', () => { + expect(formatLibraryFileSize(2_621_440)).toBe('2.5 MB'); + }); + }); + + describe('getStorageUsagePercent', () => { + it('calculates percentage', () => { + expect(getStorageUsagePercent(50, 100)).toBe(50); + }); + + it('clamps to 100', () => { + expect(getStorageUsagePercent(150, 100)).toBe(100); + }); + }); + + describe('filterLibraryItemsBySearch', () => { + it('returns all items for empty search', () => { + expect(filterLibraryItemsBySearch(mockLibraryItems, '')).toHaveLength( + mockLibraryItems.length, + ); + }); + + it('filters by name case-insensitively', () => { + const results = filterLibraryItemsBySearch(mockLibraryItems, 'pdf'); + expect( + results.every( + (item) => + item.name.toLowerCase().includes('pdf') || + item.description?.toLowerCase().includes('pdf'), + ), + ).toBe(true); + }); + }); + + describe('filterLibraryItemsByType', () => { + it('returns all items for "all" filter', () => { + expect(filterLibraryItemsByType(mockLibraryItems, 'all')).toHaveLength( + mockLibraryItems.length, + ); + }); + + it('filters by type', () => { + const results = filterLibraryItemsByType(mockLibraryItems, 'image'); + expect(results.every((item) => item.type === 'image')).toBe(true); + }); + }); + + describe('sortLibraryItems', () => { + it('sorts by newest first', () => { + const sorted = sortLibraryItems(mockLibraryItems, 'newest'); + expect(new Date(sorted[0].createdAt).getTime()).toBeGreaterThanOrEqual( + new Date(sorted[1].createdAt).getTime(), + ); + }); + + it('sorts by name', () => { + const sorted = sortLibraryItems(mockLibraryItems, 'name'); + expect(sorted[0].name.localeCompare(sorted[1].name)).toBeLessThanOrEqual(0); + }); + + it('sorts by size descending', () => { + const sorted = sortLibraryItems(mockLibraryItems, 'size'); + expect(sorted[0].sizeBytes).toBeGreaterThanOrEqual(sorted[1].sizeBytes); + }); + }); + + describe('applyLibraryFilters', () => { + it('combines search, type filter, and sort', () => { + const results = applyLibraryFilters(mockLibraryItems, { + search: 'api', + typeFilter: 'code', + sortBy: 'name', + }); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('api-client.ts'); + }); + }); + + describe('parseLibraryTypeFilter', () => { + it('returns valid type filters unchanged', () => { + expect(parseLibraryTypeFilter('pdf')).toBe('pdf'); + expect(parseLibraryTypeFilter('all')).toBe('all'); + }); + + it('falls back to all for invalid values', () => { + expect(parseLibraryTypeFilter('invalid')).toBe('all'); + }); + }); + + describe('parseLibrarySortOption', () => { + it('returns valid sort options unchanged', () => { + expect(parseLibrarySortOption('name')).toBe('name'); + expect(parseLibrarySortOption('newest')).toBe('newest'); + }); + + it('falls back to newest for invalid values', () => { + expect(parseLibrarySortOption('invalid')).toBe('newest'); + }); + }); + + describe('findLibraryOption', () => { + it('finds matching option by value', () => { + expect(findLibraryOption(createLibraryTypeFilterOptions(localize), 'pdf').label).toBe('PDF'); + }); + + it('returns fallback option when value is missing', () => { + expect(findLibraryOption(createLibrarySortOptions(localize), 'missing').value).toBe('newest'); + }); + }); +}); diff --git a/client/src/components/Library/index.ts b/client/src/components/Library/index.ts new file mode 100644 index 0000000..83dd57f --- /dev/null +++ b/client/src/components/Library/index.ts @@ -0,0 +1,10 @@ +export { default as LibraryList } from './LibraryList'; +export { default as LibraryListItem } from './LibraryListItem'; +export { default as LibraryFilters } from './LibraryFilters'; +export { default as LibraryEmptyState } from './LibraryEmptyState'; +export { default as LibraryLoadingState } from './LibraryLoadingState'; +export { default as LibraryErrorState } from './LibraryErrorState'; +export { default as LibraryStorageBar } from './LibraryStorageBar'; +export { default as LibraryListWithMockData } from './LibraryListWithMockData'; +export { useMockLibraryData } from './useMockLibraryData'; +export * from './libraryUtils'; diff --git a/client/src/components/Library/libraryTypeMeta.ts b/client/src/components/Library/libraryTypeMeta.ts new file mode 100644 index 0000000..d3e289e --- /dev/null +++ b/client/src/components/Library/libraryTypeMeta.ts @@ -0,0 +1,59 @@ +import { + AudioPaths, + CodePaths, + FilePaths, + SheetPaths, + TextPaths, + VideoPaths, +} from '@librechat/client'; +import type { LocalizeFunction } from '~/common'; +import type { LibraryItemType } from '~/types/library'; +import type { TranslationKeys } from '~/hooks/useLocalize'; + +export interface LibraryFileType { + fill: string; + paths: React.FC; + title: string; +} + +type LibraryFileTypeConfig = Omit & { titleKey: TranslationKeys }; + +export const TYPE_FILE_TYPE_MAP: Record< + Exclude, + LibraryFileTypeConfig +> = { + pdf: { paths: TextPaths, fill: '#EF4444', titleKey: 'com_ui_library_pdf' }, + image: { paths: FilePaths, fill: '#EC4899', titleKey: 'com_ui_library_image' }, + video: { paths: VideoPaths, fill: '#A855F7', titleKey: 'com_ui_library_video' }, + document: { paths: TextPaths, fill: '#3B82F6', titleKey: 'com_ui_library_document' }, + code: { paths: CodePaths, fill: '#F97316', titleKey: 'com_ui_code' }, + spreadsheet: { + paths: SheetPaths, + fill: '#10B981', + titleKey: 'com_ui_library_spreadsheet', + }, + audio: { paths: AudioPaths, fill: '#F59E0B', titleKey: 'com_ui_library_audio' }, +}; + +export const TYPE_COLOR_MAP: Record = { + pdf: 'text-red-500', + image: 'text-pink-500', + video: 'text-purple-500', + document: 'text-blue-500', + code: 'text-orange-500', + spreadsheet: 'text-emerald-500', + audio: 'text-amber-500', + artifact: 'text-violet-500', +}; + +export function getLibraryFileType( + type: LibraryItemType, + localize: LocalizeFunction, +): LibraryFileType | null { + if (type === 'artifact') { + return null; + } + + const { titleKey, ...fileType } = TYPE_FILE_TYPE_MAP[type]; + return { ...fileType, title: localize(titleKey) }; +} diff --git a/client/src/components/Library/libraryUtils.ts b/client/src/components/Library/libraryUtils.ts new file mode 100644 index 0000000..f98e979 --- /dev/null +++ b/client/src/components/Library/libraryUtils.ts @@ -0,0 +1,170 @@ +import type { LocalizeFunction, Option } from '~/common'; +import type { + LibraryFiltersState, + LibraryItem, + LibraryItemType, + LibrarySortOption, + LibraryTypeFilter, +} from '~/types/library'; +import type { TranslationKeys } from '~/hooks/useLocalize'; + +/** Translation keys for each library item type. */ +export const LIBRARY_TYPE_LABEL_KEYS: Record = { + pdf: 'com_ui_library_pdf', + image: 'com_ui_library_image', + video: 'com_ui_library_video', + document: 'com_ui_library_document', + code: 'com_ui_code', + spreadsheet: 'com_ui_library_spreadsheet', + audio: 'com_ui_library_audio', + artifact: 'com_ui_library_artifact', +}; + +/** Translation keys for the sort dropdown. */ +export const LIBRARY_SORT_LABEL_KEYS: Record = { + newest: 'com_ui_library_newest_first', + oldest: 'com_ui_library_oldest_first', + name: 'com_ui_library_name_az', + size: 'com_ui_library_size_largest', +}; + +/** All filterable types in display order. */ +export const LIBRARY_FILTER_TYPES: LibraryItemType[] = [ + 'pdf', + 'image', + 'video', + 'document', + 'code', + 'spreadsheet', + 'audio', + 'artifact', +]; + +const LIBRARY_TYPE_FILTER_VALUES = new Set(['all', ...LIBRARY_FILTER_TYPES]); +const LIBRARY_SORT_OPTION_VALUES = new Set(Object.keys(LIBRARY_SORT_LABEL_KEYS)); + +export function getLibraryTypeLabel(type: LibraryItemType, localize: LocalizeFunction): string { + return localize(LIBRARY_TYPE_LABEL_KEYS[type]); +} + +/** Builds localized dropdown options for the type filter control. */ +export function createLibraryTypeFilterOptions(localize: LocalizeFunction): Option[] { + return [ + { value: 'all', label: localize('com_ui_library_all_types') }, + ...LIBRARY_FILTER_TYPES.map((type) => ({ + value: type, + label: getLibraryTypeLabel(type, localize), + })), + ]; +} + +/** Builds localized dropdown options for the sort control. */ +export function createLibrarySortOptions(localize: LocalizeFunction): Option[] { + return (Object.entries(LIBRARY_SORT_LABEL_KEYS) as [LibrarySortOption, TranslationKeys][]).map( + ([value, labelKey]) => ({ value, label: localize(labelKey) }), + ); +} + +/** Resolves a dropdown value to a valid type filter, falling back to `all`. */ +export function parseLibraryTypeFilter(value: string): LibraryTypeFilter { + return LIBRARY_TYPE_FILTER_VALUES.has(value) ? (value as LibraryTypeFilter) : 'all'; +} + +/** Resolves a dropdown value to a valid sort option, falling back to `newest`. */ +export function parseLibrarySortOption(value: string): LibrarySortOption { + return LIBRARY_SORT_OPTION_VALUES.has(value) ? (value as LibrarySortOption) : 'newest'; +} + +/** Finds the matching dropdown option or returns the fallback entry. */ +export function findLibraryOption(options: Option[], value: string, fallbackIndex = 0): Option { + return options.find((option) => String(option.value) === value) ?? options[fallbackIndex]; +} + +/** Formats byte count into a readable string with unit suffix. */ +export function formatLibraryFileSize(bytes: number): string { + if (bytes >= 1_073_741_824) { + return `${(bytes / 1_073_741_824).toFixed(1)} GB`; + } + if (bytes >= 1_048_576) { + return `${(bytes / 1_048_576).toFixed(1)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${bytes} B`; +} + +/** Formats an ISO date string for list display. */ +export function formatLibraryDate(isoDate: string): string { + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(isoDate)); +} + +/** Returns the storage usage percentage, clamped to 0–100. */ +export function getStorageUsagePercent(usedBytes: number, totalBytes: number): number { + if (totalBytes <= 0) { + return 0; + } + return Math.max(0, Math.min(100, Math.round((usedBytes / totalBytes) * 100))); +} + +/** Filters items by case-insensitive search across name and description. */ +export function filterLibraryItemsBySearch(items: LibraryItem[], search: string): LibraryItem[] { + const query = search.trim().toLowerCase(); + if (!query) { + return items; + } + + return items.filter( + (item) => + item.name.toLowerCase().includes(query) || item.description?.toLowerCase().includes(query), + ); +} + +/** Filters items by content type. */ +export function filterLibraryItemsByType( + items: LibraryItem[], + typeFilter: LibraryTypeFilter, +): LibraryItem[] { + if (typeFilter === 'all') { + return items; + } + return items.filter((item) => item.type === typeFilter); +} + +/** Sorts items by the selected sort option. */ +export function sortLibraryItems(items: LibraryItem[], sortBy: LibrarySortOption): LibraryItem[] { + const sorted = [...items]; + + switch (sortBy) { + case 'newest': + return sorted.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + case 'oldest': + return sorted.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + case 'name': + return sorted.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), + ); + case 'size': + return sorted.sort((a, b) => b.sizeBytes - a.sizeBytes); + default: + return sorted; + } +} + +/** Applies search, type filter, and sort in sequence. */ +export function applyLibraryFilters( + items: LibraryItem[], + filters: LibraryFiltersState, +): LibraryItem[] { + const searched = filterLibraryItemsBySearch(items, filters.search); + const filtered = filterLibraryItemsByType(searched, filters.typeFilter); + return sortLibraryItems(filtered, filters.sortBy); +} diff --git a/client/src/components/Library/useMockLibraryData.ts b/client/src/components/Library/useMockLibraryData.ts new file mode 100644 index 0000000..155b351 --- /dev/null +++ b/client/src/components/Library/useMockLibraryData.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useState } from 'react'; +import { mockLibraryResponse } from '~/data/mockLibraryData'; +import { useLocalize } from '~/hooks'; +import type { LibraryItem, LibraryListStatus, LibraryStorageInfo } from '~/types/library'; + +const MOCK_LOAD_DELAY_MS = 800; + +export type MockLibraryScenario = 'success' | 'empty' | 'error'; + +interface UseMockLibraryDataOptions { + /** Simulated network scenario. Defaults to `success`. */ + scenario?: MockLibraryScenario; + /** Artificial delay before resolving mock data (ms). */ + delayMs?: number; + /** When true, fetches on mount. Defaults to true. */ + autoFetch?: boolean; +} + +interface UseMockLibraryDataResult { + items: LibraryItem[]; + storage: LibraryStorageInfo | null; + status: LibraryListStatus; + errorMessage: string | null; + isLoading: boolean; + refetch: () => void; +} + +/** + * Simulates an async library API call with mocked data. + * Useful for development and Storybook-style previews before the real API exists. + */ +export function useMockLibraryData( + options: UseMockLibraryDataOptions = {}, +): UseMockLibraryDataResult { + const localize = useLocalize(); + const { scenario = 'success', delayMs = MOCK_LOAD_DELAY_MS, autoFetch = true } = options; + + const [items, setItems] = useState([]); + const [storage, setStorage] = useState(null); + const [status, setStatus] = useState('loading'); + const [errorMessage, setErrorMessage] = useState(null); + const [fetchKey, setFetchKey] = useState(0); + + const refetch = useCallback(() => { + setFetchKey((key) => key + 1); + }, []); + + useEffect(() => { + if (!autoFetch) { + return; + } + + let cancelled = false; + + const load = async () => { + setStatus('loading'); + setErrorMessage(null); + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + if (cancelled) { + return; + } + + if (scenario === 'error') { + setItems([]); + setStorage(null); + setStatus('error'); + setErrorMessage(localize('com_ui_library_error_service')); + return; + } + + if (scenario === 'empty') { + setItems([]); + setStorage(mockLibraryResponse.storage); + setStatus('empty'); + return; + } + + const response = mockLibraryResponse; + setItems(response.items); + setStorage(response.storage); + setStatus(response.items.length > 0 ? 'populated' : 'empty'); + }; + + load(); + + return () => { + cancelled = true; + }; + }, [autoFetch, delayMs, scenario, fetchKey, localize]); + + return { + items, + storage, + status, + errorMessage, + isLoading: status === 'loading', + refetch, + }; +} diff --git a/client/src/data/mockLibraryData.ts b/client/src/data/mockLibraryData.ts new file mode 100644 index 0000000..efbc555 --- /dev/null +++ b/client/src/data/mockLibraryData.ts @@ -0,0 +1,142 @@ +import type { LibraryItem, LibraryListResponse, LibraryStorageInfo } from '~/types/library'; + +/** Mock library items covering all supported types and both kinds. */ +export const mockLibraryItems: LibraryItem[] = [ + { + id: 'file_financial_report', + name: 'Q4 Financial Report.pdf', + type: 'pdf', + kind: 'file', + sizeBytes: 2_458_880, + createdAt: '2026-05-28T14:22:00.000Z', + description: 'Quarterly financial summary and projections', + contentType: 'application/pdf', + }, + { + id: 'file_product_mockup', + name: 'product-mockup-v3.png', + type: 'image', + kind: 'file', + sizeBytes: 1_572_864, + createdAt: '2026-06-01T09:15:00.000Z', + description: 'Latest product UI mockup', + contentType: 'image/png', + }, + { + id: 'file_onboarding_demo', + name: 'onboarding-demo.mp4', + type: 'video', + kind: 'file', + sizeBytes: 45_875_200, + createdAt: '2026-05-15T11:30:00.000Z', + description: 'Product onboarding walkthrough', + }, + { + id: 'file_project_proposal', + name: 'Project Proposal.docx', + type: 'document', + kind: 'file', + sizeBytes: 524_288, + createdAt: '2026-04-20T16:45:00.000Z', + description: 'Client project proposal draft', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + id: 'artifact_api_client', + name: 'api-client.ts', + type: 'code', + kind: 'artifact', + sizeBytes: 12_400, + createdAt: '2026-06-08T08:10:00.000Z', + description: 'Generated TypeScript API client', + conversationId: 'conv-abc123', + }, + { + id: 'file_sales_data', + name: 'sales-data-2026.xlsx', + type: 'spreadsheet', + kind: 'file', + sizeBytes: 892_160, + createdAt: '2026-05-02T13:00:00.000Z', + description: 'Annual sales data export', + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + { + id: 'file_meeting_recording', + name: 'meeting-recording.mp3', + type: 'audio', + kind: 'file', + sizeBytes: 8_388_608, + createdAt: '2026-03-18T10:20:00.000Z', + description: 'Team standup recording', + }, + { + id: 'artifact_dashboard_component', + name: 'dashboard-component.tsx', + type: 'artifact', + kind: 'artifact', + sizeBytes: 18_750, + createdAt: '2026-06-09T17:45:00.000Z', + description: 'React dashboard component generated in chat', + conversationId: 'conv-def456', + }, + { + id: 'file_architecture_diagram', + name: 'architecture-diagram.pdf', + type: 'pdf', + kind: 'file', + sizeBytes: 1_048_576, + createdAt: '2026-02-10T12:00:00.000Z', + description: 'System architecture overview', + contentType: 'application/pdf', + }, + { + id: 'artifact_python_utils', + name: 'utils.py', + type: 'code', + kind: 'artifact', + sizeBytes: 6_200, + createdAt: '2026-06-07T14:30:00.000Z', + description: 'Python utility functions', + conversationId: 'conv-ghi789', + }, + { + id: 'file_team_photo', + name: 'team-photo.jpg', + type: 'image', + kind: 'file', + sizeBytes: 3_145_728, + createdAt: '2026-01-25T18:00:00.000Z', + description: 'Company team photo', + contentType: 'image/jpeg', + }, + { + id: 'artifact_readme', + name: 'README.md', + type: 'document', + kind: 'artifact', + sizeBytes: 4_800, + createdAt: '2026-06-06T11:00:00.000Z', + description: 'Project documentation generated in chat', + conversationId: 'conv-jkl012', + contentType: 'text/markdown', + }, +]; + +/** Mock storage quota — 64.2 MB used of 1 GB. */ +export const mockLibraryStorage: LibraryStorageInfo = { + usedBytes: 67_289_344, + totalBytes: 1_073_741_824, +}; + +/** Full mock API response shape. */ +export const mockLibraryResponse: LibraryListResponse = { + items: mockLibraryItems, + storage: mockLibraryStorage, +}; + +/** Empty library response for testing empty state. */ +export const mockEmptyLibraryResponse: LibraryListResponse = { + items: [], + storage: { usedBytes: 0, totalBytes: 1_073_741_824 }, +}; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index f8c859f..66dec18 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1144,6 +1144,49 @@ "com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_version": "Latest version", "com_ui_leave_blank_to_keep": "Leave blank to keep existing", + "com_ui_library_all_types": "All types", + "com_ui_library_artifact": "Artifact", + "com_ui_library_audio": "Audio", + "com_ui_library_chat_source": "Chat {{chatId}}", + "com_ui_library_clear_filters": "Clear filters", + "com_ui_library_dense_view": "Dense list view", + "com_ui_library_document": "Document", + "com_ui_library_empty_description": "Files and artifacts from your conversations will appear here.", + "com_ui_library_empty_title": "Your library is empty", + "com_ui_library_error_default": "Something went wrong while loading your library.", + "com_ui_library_error_service": "Failed to connect to the library service. Please try again.", + "com_ui_library_error_title": "Unable to load library", + "com_ui_library_filter_by_type": "Filter by type", + "com_ui_library_image": "Image", + "com_ui_library_item_label": "{{name}}, {{type}}, {{size}}", + "com_ui_library_items": "Library items", + "com_ui_library_items_count_one": "Showing {{resultCount}} of {{totalCount}} item", + "com_ui_library_items_count_other": "Showing {{resultCount}} of {{totalCount}} items", + "com_ui_library_loading": "Loading library", + "com_ui_library_more_options": "More options", + "com_ui_library_name_az": "Name (A–Z)", + "com_ui_library_newest_first": "Newest first", + "com_ui_library_no_matches_description": "Try adjusting your search or filter to find what you are looking for.", + "com_ui_library_no_matches_title": "No matching files", + "com_ui_library_oldest_first": "Oldest first", + "com_ui_library_open": "Open", + "com_ui_library_open_item": "Open {{name}}", + "com_ui_library_pdf": "PDF", + "com_ui_library_preview_view": "Preview grid view", + "com_ui_library_quick_actions": "Quick actions", + "com_ui_library_search": "Search library", + "com_ui_library_search_placeholder": "Search files and artifacts...", + "com_ui_library_size_largest": "Size (largest)", + "com_ui_library_sort_items": "Sort library items", + "com_ui_library_spreadsheet": "Spreadsheet", + "com_ui_library_storage": "Storage", + "com_ui_library_storage_percent": "Storage {{percent}}% used", + "com_ui_library_storage_usage": "{{used}} of {{total}} used", + "com_ui_library_try_again": "Try again", + "com_ui_library_type_artifact": "Artifact", + "com_ui_library_type_file": "File", + "com_ui_library_video": "Video", + "com_ui_library_view": "Library view", "com_ui_light_theme_enabled": "Light theme enabled", "com_ui_line_count": "{0} line", "com_ui_lines_count": "{0} lines", diff --git a/client/src/types/library.ts b/client/src/types/library.ts new file mode 100644 index 0000000..6e9e0e4 --- /dev/null +++ b/client/src/types/library.ts @@ -0,0 +1,63 @@ +/** Supported library item content types for filtering. */ +export type LibraryItemType = + | 'pdf' + | 'image' + | 'video' + | 'document' + | 'code' + | 'spreadsheet' + | 'audio' + | 'artifact'; + +/** Distinguishes uploaded files from generated artifacts. */ +export type LibraryItemKind = 'file' | 'artifact'; + +/** Available sort options for the library list. */ +export type LibrarySortOption = 'newest' | 'oldest' | 'name' | 'size'; + +/** View modes for the library artifact list. */ +export type LibraryViewMode = 'dense' | 'preview'; + +/** UI state for the library list container. */ +export type LibraryListStatus = 'loading' | 'empty' | 'populated' | 'error'; + +/** Type filter value — `all` shows every item. */ +export type LibraryTypeFilter = LibraryItemType | 'all'; + +export interface LibraryItem { + id: FileRecord['id'] | ArtifactRecord['id']; + name: FileRecord['name'] | ArtifactRecord['name']; + type: LibraryItemType; + kind: LibraryItemKind; + sizeBytes: FileRecord['sizeBytes']; + createdAt: NonNullable; + updatedAt?: FileRecord['updatedAt'] | ArtifactRecord['updatedAt']; + description?: string; + conversationId?: string; + contentType?: SupportedArtifactContentType; +} + +export interface LibraryStorageInfo { + usedBytes: number; + totalBytes: number; +} + +export interface LibraryFiltersState { + search: string; + typeFilter: LibraryTypeFilter; + sortBy: LibrarySortOption; +} + +export interface LibraryViewState { + viewMode: LibraryViewMode; +} + +export interface LibraryListResponse { + items: LibraryItem[]; + storage: LibraryStorageInfo | null; +} +import type { + ArtifactRecord, + FileRecord, + SupportedArtifactContentType, +} from 'librechat-data-provider'; diff --git a/docs/superpowers/plans/2026-07-21-library-ui-pr27-takeover.md b/docs/superpowers/plans/2026-07-21-library-ui-pr27-takeover.md new file mode 100644 index 0000000..85a47cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-library-ui-pr27-takeover.md @@ -0,0 +1,294 @@ +# Library UI PR #27 Takeover Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the contributor-authored Library list prototype from PR #27 onto current `main`, resolve its review blockers, and publish a current, tested maintainer replacement PR that preserves contributor attribution. + +**Architecture:** Keep the Library feature as an isolated client-side prototype backed by mock data. Reuse the contributor's dense and preview views, expose only quick actions that have real callbacks, use pressed-button group semantics for the view toggle, localize every user-facing string, and type the UI view model against the merged RFC 0001 file/artifact contract where that contract applies. + +**Tech Stack:** React, TypeScript, Headless UI, React Testing Library, Jest, i18next, Vite 7/PWA runtime smoke. + +## Global Constraints + +- Start from public `main` at `f996b7ed685225d2655b57762fbbeb6ada7e82a9` or a later descendant containing PR #80. +- Preserve the original author metadata by cherry-picking contributor commits; do not squash-copy the feature. +- Do not import PR #27's stale merge commit `c000bf49353f99c07981e54625692c9f86a089f8`. +- Keep this slice mocked and contributor-safe: no production credentials, storage adapters, private endpoints, or deployment configuration. +- All user-facing text in `client/src/**/*` must use `useLocalize()` and new keys must be added only to `client/src/locales/en/translation.json`. +- Write or update a failing focused test before each behavior change, observe the expected failure, then implement the minimum fix. +- Preserve unrelated worktree changes. + +--- + +### Task 1: Replay the contributor implementation and reproduce the reviewed failures + +**Files:** +- Import: `.gitignore` +- Import: `client/src/components/Library/**/*` +- Import: `client/src/data/mockLibraryData.ts` +- Import: `client/src/types/library.ts` +- Test: `client/src/components/Library/__tests__/LibraryList.test.tsx` +- Test: `client/src/components/Library/__tests__/libraryUtils.test.ts` + +**Interfaces:** +- Consumes: PR #27 commits `aa888afc664977550ad5efee578b93e99d2aca84`, `857c58384ae4c81262127ded0c0d61bd8ca9ebfe`, `81247c0ad19083b49f2e356b74e01a5eeef5894d`, and `9db1d77d2caf016288d602fd8890bbd3a3a8bb54`. +- Produces: the contributor's Library components and tests on the repaired current toolchain. + +- [ ] **Step 1: Replay only the contributor-authored commits** + +```bash +git cherry-pick aa888afc664977550ad5efee578b93e99d2aca84 +git cherry-pick 857c58384ae4c81262127ded0c0d61bd8ca9ebfe +git cherry-pick 81247c0ad19083b49f2e356b74e01a5eeef5894d +git cherry-pick 9db1d77d2caf016288d602fd8890bbd3a3a8bb54 +``` + +- [ ] **Step 2: Run the focused Library tests and verify the known red state** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: `LibraryList › filters items by type` and `LibraryList › sorts items by name` fail because the actual dropdown buttons are not named “Filter by type” and “Sort library items”. + +### Task 2: Correct accessible control and quick-action semantics + +**Files:** +- Modify: `client/src/components/Library/__tests__/LibraryList.test.tsx` +- Modify: `client/src/components/Library/LibraryFilters.tsx` +- Modify: `client/src/components/Library/LibraryViewToggle.tsx` +- Modify: `client/src/components/Library/LibraryItemActions.tsx` +- Modify: `client/src/components/Library/LibraryListItem.tsx` +- Modify: `client/src/components/Library/LibraryPreviewCard.tsx` +- Modify: `client/src/components/Library/LibraryList.tsx` +- Modify: `client/src/types/library.ts` + +**Interfaces:** +- Consumes: `LibraryList` items and optional item-level callbacks. +- Produces: correctly named filter/sort buttons; a `role="group"` pressed-button view toggle; and optional `onDownload`, `onShare`, and `onMore` callbacks that never render dead controls. + +- [ ] **Step 1: Extend the interaction tests before changing components** + +Add assertions equivalent to: + +```tsx +expect(screen.getByRole('group', { name: 'Library view' })).toBeInTheDocument(); +expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument(); + +const onOpen = jest.fn(); +const onDownload = jest.fn(); +render(); +await userEvent.click(screen.getByRole('button', { name: 'Download' })); +expect(onDownload).toHaveBeenCalledWith(mockLibraryItems[0]); +``` + +- [ ] **Step 2: Run the focused component test and verify the new assertions fail** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx --runInBand --coverage=false +``` + +Expected: failures identify the radiogroup/pressed-button mismatch and dead quick-action buttons; the two existing dropdown tests remain red. + +- [ ] **Step 3: Implement the minimum accessible behavior** + +Implement these exact behavior rules: + +```tsx + + +
+``` + +`LibraryItemActions` must return `null` when every callback is absent and render each button only when its corresponding callback exists. `LibraryList` must pass the selected `LibraryItem` to `onItemClick`, `onDownload`, `onShare`, and `onMore` without adding placeholder handlers. + +- [ ] **Step 4: Run the focused component test and verify it passes** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx --runInBand --coverage=false +``` + +Expected: all Library component interaction tests pass. + +- [ ] **Step 5: Commit the behavior fixes** + +```bash +git add client/src/components/Library client/src/types/library.ts +git commit -m "fix: complete Library interaction semantics" +``` + +### Task 3: Align mock records with the RFC 0001 contract + +**Files:** +- Modify: `client/src/components/Library/__tests__/libraryUtils.test.ts` +- Modify: `client/src/components/Library/LibraryPreviewCard.tsx` +- Modify: `client/src/data/mockLibraryData.ts` +- Modify: `client/src/types/library.ts` + +**Interfaces:** +- Consumes: `FileRecord`, `ArtifactRecord`, `SupportedArtifactContentType`, and `isSupportedArtifactContentType` from `librechat-data-provider`. +- Produces: Library mock records whose IDs identify files/artifacts consistently and whose optional `contentType` values are accepted by the shared artifact contract. + +- [ ] **Step 1: Add a failing contract-alignment test** + +```tsx +it('keeps mock records aligned with shared file and artifact identifiers', () => { + for (const item of mockLibraryItems) { + expect(item.id).toMatch(item.kind === 'file' ? /^file_/ : /^artifact_/); + if (item.contentType) { + expect(isSupportedArtifactContentType(item.contentType)).toBe(true); + } + } +}); +``` + +- [ ] **Step 2: Run the utility test and verify it fails on the `lib-*` IDs or unsupported MIME values** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: the new contract-alignment test fails against the imported mock records. + +- [ ] **Step 3: Type and update the mock records** + +Use shared types in the view model: + +```ts +import type { ArtifactRecord, FileRecord, SupportedArtifactContentType } from 'librechat-data-provider'; + +export interface LibraryItem { + id: FileRecord['id'] | ArtifactRecord['id']; + name: FileRecord['name'] | ArtifactRecord['name']; + contentType?: SupportedArtifactContentType; +} +``` + +Rename `mimeType` to `contentType`, change file IDs to `file_*`, change artifact IDs to `artifact_*`, keep `contentType` only for values supported by RFC 0001, and make preview detection read `contentType`. + +- [ ] **Step 4: Run both focused Library suites and verify they pass** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: both suites pass with zero failing tests. + +- [ ] **Step 5: Commit the contract alignment** + +```bash +git add client/src/components/Library client/src/data/mockLibraryData.ts client/src/types/library.ts +git commit -m "refactor: align Library mocks with artifact contracts" +``` + +### Task 4: Localize the Library surface and remove literal-string exemptions + +**Files:** +- Modify: `client/src/components/Library/*.tsx` +- Modify: `client/src/components/Library/libraryUtils.ts` +- Modify: `client/src/components/Library/__tests__/LibraryList.test.tsx` +- Modify: `client/src/components/Library/__tests__/libraryUtils.test.ts` +- Modify: `client/src/locales/en/translation.json` + +**Interfaces:** +- Consumes: `useLocalize()` and `TranslationKeys`. +- Produces: localized labels, descriptions, counts, dates, storage text, action names, and type/sort options with English defaults in the canonical locale file. + +- [ ] **Step 1: Run ESLint against the imported Library files and record the literal-string red state** + +Run: + +```bash +npx eslint 'client/src/components/Library/**/*.{ts,tsx}' 'client/src/data/mockLibraryData.ts' 'client/src/types/library.ts' +``` + +Expected: the imported components report literal user-facing strings or other current-main lint violations. + +- [ ] **Step 2: Add canonical English translation keys** + +Add `com_ui_library_*` keys for search, filter/sort, item types, sort choices, list counts, empty/error/loading states, storage usage, view modes, source labels, and quick actions. Reuse existing `com_ui_download`, `com_ui_share`, `com_ui_retry`, and `com_ui_clear_search` where their wording is exact. + +- [ ] **Step 3: Route every user-facing Library string through `useLocalize()`** + +`libraryUtils.ts` must store translation-key maps and accept a `LocalizeFunction` when building labels/options. Components must call `useLocalize()` and remove all `i18next/no-literal-string` disable comments. Tests must import `render` from `~/test/layout-test-utils` so Recoil/i18n providers are present. + +- [ ] **Step 4: Run lint and focused tests** + +Run: + +```bash +npx eslint 'client/src/components/Library/**/*.{ts,tsx}' 'client/src/data/mockLibraryData.ts' 'client/src/types/library.ts' +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: ESLint exits zero and both focused suites pass. + +- [ ] **Step 5: Commit localization** + +```bash +git add client/src/components/Library client/src/locales/en/translation.json +git commit -m "feat: localize Library prototype" +``` + +### Task 5: Verify the current-main integration and publish the replacement PR + +**Files:** +- Verify: all changed files +- Update: PR description only through GitHub after local verification + +**Interfaces:** +- Consumes: completed Library prototype and public CI commands. +- Produces: a pushed maintainer branch and replacement PR referencing #27 and #20. + +- [ ] **Step 1: Run focused, full-client, build, config, and diff checks** + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +npm test -- --runInBand +cd .. +node --test bharatcode/test/config.test.mjs +npm run frontend:ci +git diff --check origin/main...HEAD +git status --short +``` + +Expected: all commands exit zero, no test failures occur, the production runtime smoke passes, and only intended files are changed. + +- [ ] **Step 2: Review the commit range against this plan** + +```bash +git diff --stat origin/main...HEAD +git log --oneline --reverse origin/main..HEAD +``` + +Expected: four contributor-authored commits plus focused maintainer commits; no private deployment files or stale merge commit. + +- [ ] **Step 3: Push and create the replacement PR** + +```bash +git push -u origin codex/takeover-library-ui-pr27 +gh pr create --repo BharatCode-ai/chat --base main --head codex/takeover-library-ui-pr27 --title "feat: complete accessible Library list prototype" --body-file /tmp/pr-body.md +``` + +The PR body must reference PR #27, close issue #20, credit the contributor commits, summarize the accessibility/action/contract/localization work, and list the exact verification commands. + +- [ ] **Step 4: Wait for current CI and perform final maintainer review** + +Expected required checks: BharatCode config tests, Gitleaks, shared packages/API build, shared packages/client build including the production-runtime smoke.