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..54a52be --- /dev/null +++ b/client/src/components/Library/LibraryEmptyState.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { FolderOpen, SearchX } from 'lucide-react'; + +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) { + if (isFiltered) { + return ( + + {onClearFilters && ( + + )} + + ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryErrorState.tsx b/client/src/components/Library/LibraryErrorState.tsx new file mode 100644 index 0000000..434bc68 --- /dev/null +++ b/client/src/components/Library/LibraryErrorState.tsx @@ -0,0 +1,38 @@ +import { AlertCircle } from 'lucide-react'; +import { Button } from '@librechat/client'; + +interface LibraryErrorStateProps { + message?: string; + onRetry?: () => void; +} + +export default function LibraryErrorState({ + message = 'Something went wrong while loading your library.', + onRetry, +}: LibraryErrorStateProps) { + return ( +
+
+
+

Unable to load library

+

{message}

+ {onRetry && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryFilters.tsx b/client/src/components/Library/LibraryFilters.tsx new file mode 100644 index 0000000..0165de3 --- /dev/null +++ b/client/src/components/Library/LibraryFilters.tsx @@ -0,0 +1,152 @@ +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 } from '~/hooks'; +import { createDropdownSetter } from '~/utils'; +import { + findLibraryOption, + LIBRARY_SORT_OPTIONS, + LIBRARY_TYPE_FILTER_OPTIONS, + 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 [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 selectedTypeOption = findLibraryOption(LIBRARY_TYPE_FILTER_OPTIONS, filters.typeFilter); + + const selectedSortOption = findLibraryOption(LIBRARY_SORT_OPTIONS, 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], + ); + + const itemLabel = totalCount === 1 ? 'item' : 'items'; + + return ( +
+
+
+ {/* eslint-disable-next-line i18next/no-literal-string */} + + setSearchInput(event.target.value)} + placeholder="Search files and artifacts..." + 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="Search library" + autoComplete="off" + spellCheck={false} + /> +
+ +
+
+ +
+
+ +
+ +
+
+ + {/* eslint-disable i18next/no-literal-string */} +

+ Showing {resultCount} of {totalCount} {itemLabel} +

+ {/* eslint-enable i18next/no-literal-string */} +
+ ); +} diff --git a/client/src/components/Library/LibraryItemActions.tsx b/client/src/components/Library/LibraryItemActions.tsx new file mode 100644 index 0000000..c5da7b7 --- /dev/null +++ b/client/src/components/Library/LibraryItemActions.tsx @@ -0,0 +1,63 @@ +import { Download, ExternalLink, MoreVertical, Share2 } from 'lucide-react'; +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) { + return ( +
event.stopPropagation()} + role="group" + aria-label="Quick actions" + > + + + + +
+ ); +} diff --git a/client/src/components/Library/LibraryList.tsx b/client/src/components/Library/LibraryList.tsx new file mode 100644 index 0000000..757f2bb --- /dev/null +++ b/client/src/components/Library/LibraryList.tsx @@ -0,0 +1,119 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { + LibraryFiltersState, + LibraryItem, + LibraryListStatus, + LibraryStorageInfo, + LibraryViewMode, +} from '~/types/library'; +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; + className?: string; +} + +export default function LibraryList({ + items, + status, + storage = null, + errorMessage, + onRetry, + onItemClick, + className = '', +}: LibraryListProps) { + 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 && } +
+ +
+
+ ); + } + + return ( +
+ {storage && } + + + + {filteredItems.length === 0 ? ( + + ) : viewMode === 'preview' ? ( +
+ {filteredItems.map((item) => ( + + ))} +
+ ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryListItem.tsx b/client/src/components/Library/LibraryListItem.tsx new file mode 100644 index 0000000..297c98f --- /dev/null +++ b/client/src/components/Library/LibraryListItem.tsx @@ -0,0 +1,80 @@ +import { FileIcon, Sparkles } from '@librechat/client'; +import type { LibraryItem } from '~/types/library'; +import { cn } from '~/utils'; +import { formatLibraryDate, formatLibraryFileSize, LIBRARY_TYPE_LABELS } from './libraryUtils'; +import { getLibraryFileType, TYPE_COLOR_MAP } from './libraryTypeMeta'; +import LibraryItemActions from './LibraryItemActions'; + +interface LibraryListItemProps { + item: LibraryItem; + onClick?: (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 }: LibraryListItemProps) { + const fileType = getLibraryFileType(item.type); + const isArtifact = item.type === 'artifact'; + const iconColor = TYPE_COLOR_MAP[item.type]; + const ariaLabel = `${item.name}, ${LIBRARY_TYPE_LABELS[item.type]}, ${formatLibraryFileSize(item.sizeBytes)}`; + + const content = ( + <> + + +
+
+

{item.name}

+ + {formatLibraryFileSize(item.sizeBytes)} + +
+
+ + {LIBRARY_TYPE_LABELS[item.type]} + + {item.kind} + + +
+ {item.description && ( +

{item.description}

+ )} +
+ + ); + + return ( +
+ {onClick ? ( + + ) : ( +
{content}
+ )} + +
+ ); +} diff --git a/client/src/components/Library/LibraryListWithMockData.tsx b/client/src/components/Library/LibraryListWithMockData.tsx new file mode 100644 index 0000000..1a08a7c --- /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 ( + + ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryLoadingState.tsx b/client/src/components/Library/LibraryLoadingState.tsx new file mode 100644 index 0000000..e9d81ad --- /dev/null +++ b/client/src/components/Library/LibraryLoadingState.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from '@librechat/client'; + +const SKELETON_ROWS = 6; + +export default function LibraryLoadingState() { + return ( +
+
+ +
+ + +
+
+ +
+ {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryPreviewCard.tsx b/client/src/components/Library/LibraryPreviewCard.tsx new file mode 100644 index 0000000..45ba5ff --- /dev/null +++ b/client/src/components/Library/LibraryPreviewCard.tsx @@ -0,0 +1,177 @@ +import { FileIcon, Sparkles } from '@librechat/client'; +import type { LibraryItem } from '~/types/library'; +import { cn } from '~/utils'; +import { formatLibraryDate, formatLibraryFileSize, LIBRARY_TYPE_LABELS } from './libraryUtils'; +import { getLibraryFileType, TYPE_COLOR_MAP } from './libraryTypeMeta'; +import LibraryItemActions from './LibraryItemActions'; + +interface LibraryPreviewCardProps { + item: LibraryItem; + onClick?: (item: LibraryItem) => void; +} + +function isPresentation(item: LibraryItem): boolean { + return item.name.toLowerCase().endsWith('.pptx') || item.mimeType?.includes('presentation') === true; +} + +function isWordOrMarkdown(item: LibraryItem): boolean { + const lower = item.name.toLowerCase(); + return lower.endsWith('.docx') || lower.endsWith('.md') || item.mimeType?.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 }: { item: LibraryItem }) { + if (item.type === 'pdf' || isWordOrMarkdown(item)) { + return ; + } + if (item.type === 'spreadsheet') { + return ; + } + if (item.type === 'code' || (item.mimeType && item.mimeType.includes('html'))) { + return ; + } + if (isPresentation(item)) { + return ; + } + + const fileType = getLibraryFileType(item.type); + const iconColor = TYPE_COLOR_MAP[item.type]; + const isArtifact = item.type === 'artifact'; + + return ( + + ); +} + +export default function LibraryPreviewCard({ item, onClick }: LibraryPreviewCardProps) { + const fileType = getLibraryFileType(item.type); + const isArtifact = item.type === 'artifact'; + const iconColor = TYPE_COLOR_MAP[item.type]; + const ariaLabel = `${item.name}, ${LIBRARY_TYPE_LABELS[item.type]}, ${formatLibraryFileSize(item.sizeBytes)}`; + + const sourceLabel = item.conversationId ? `Chat ${item.conversationId}` : item.kind; + + return ( +
+ + +
+
+ +

+ {item.name} +

+
+ +
+ + {LIBRARY_TYPE_LABELS[item.type]} + + {sourceLabel} +
+ +
+ + + {formatLibraryFileSize(item.sizeBytes)} +
+ +
+ Quick actions + +
+
+
+ ); +} diff --git a/client/src/components/Library/LibraryStorageBar.tsx b/client/src/components/Library/LibraryStorageBar.tsx new file mode 100644 index 0000000..bfa054e --- /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 { + formatLibraryFileSize, + getStorageUsagePercent, +} from './libraryUtils'; + +interface LibraryStorageBarProps { + storage: LibraryStorageInfo; +} + +export default function LibraryStorageBar({ storage }: LibraryStorageBarProps) { + const percent = getStorageUsagePercent(storage.usedBytes, storage.totalBytes); + + return ( +
+
+ Storage + + {formatLibraryFileSize(storage.usedBytes)} of {formatLibraryFileSize(storage.totalBytes)}{' '} + used + +
+ +

{percent}%

+
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryViewToggle.tsx b/client/src/components/Library/LibraryViewToggle.tsx new file mode 100644 index 0000000..f46bf8c --- /dev/null +++ b/client/src/components/Library/LibraryViewToggle.tsx @@ -0,0 +1,55 @@ +import { LayoutGrid, List } from 'lucide-react'; +import type { LibraryViewMode } from '~/types/library'; +import { cn } from '~/utils'; + +interface LibraryViewToggleProps { + viewMode: LibraryViewMode; + onViewModeChange: (mode: LibraryViewMode) => void; + className?: string; +} + +export default function LibraryViewToggle({ + viewMode, + onViewModeChange, + className, +}: LibraryViewToggleProps) { + 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..7cc6351 --- /dev/null +++ b/client/src/components/Library/__tests__/LibraryList.test.tsx @@ -0,0 +1,152 @@ +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, +})); + +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: 'Retry loading library' })).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('README.md'); + }); + + it('toggles between dense and preview views', async () => { + render( + , + ); + + const list = screen.getByRole('list', { name: 'Library items' }); + expect(list).toHaveClass('flex-col'); + + 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 denseButton = screen.getByRole('button', { name: 'Dense list view' }); + await userEvent.click(denseButton); + + expect(screen.getByRole('list', { name: 'Library items' })).toHaveClass('flex-col'); + }); + + it('renders quick actions in dense view', () => { + render( + , + ); + + const list = screen.getByRole('list', { name: 'Library items' }); + const firstItem = within(list).getAllByRole('listitem')[0]; + + expect(within(firstItem).getByRole('button', { name: 'Open' })).toBeInTheDocument(); + expect(within(firstItem).getByRole('button', { name: 'Download' })).toBeInTheDocument(); + expect(within(firstItem).getByRole('button', { name: 'Share' })).toBeInTheDocument(); + expect(within(firstItem).getByRole('button', { name: 'More options' })).toBeInTheDocument(); + }); +}); 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..f37ed90 --- /dev/null +++ b/client/src/components/Library/__tests__/libraryUtils.test.ts @@ -0,0 +1,126 @@ +import { mockLibraryItems } from '~/data/mockLibraryData'; +import { + applyLibraryFilters, + filterLibraryItemsBySearch, + filterLibraryItemsByType, + findLibraryOption, + formatLibraryFileSize, + getStorageUsagePercent, + LIBRARY_SORT_OPTIONS, + LIBRARY_TYPE_FILTER_OPTIONS, + parseLibrarySortOption, + parseLibraryTypeFilter, + sortLibraryItems, +} from '../libraryUtils'; + +describe('libraryUtils', () => { + 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(LIBRARY_TYPE_FILTER_OPTIONS, 'pdf').label).toBe('PDF'); + }); + + it('returns fallback option when value is missing', () => { + expect(findLibraryOption(LIBRARY_SORT_OPTIONS, 'missing').value).toBe('newest'); + }); + }); +}); \ No newline at end of file diff --git a/client/src/components/Library/index.ts b/client/src/components/Library/index.ts new file mode 100644 index 0000000..58db893 --- /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'; \ No newline at end of file diff --git a/client/src/components/Library/libraryTypeMeta.ts b/client/src/components/Library/libraryTypeMeta.ts new file mode 100644 index 0000000..d491984 --- /dev/null +++ b/client/src/components/Library/libraryTypeMeta.ts @@ -0,0 +1,42 @@ +import { + AudioPaths, + CodePaths, + FilePaths, + SheetPaths, + TextPaths, + VideoPaths, +} from '@librechat/client'; +import type { LibraryItemType } from '~/types/library'; + +export interface LibraryFileType { + fill: string; + paths: React.FC; + title: string; +} + +export const TYPE_FILE_TYPE_MAP: Record, LibraryFileType> = { + pdf: { paths: TextPaths, fill: '#EF4444', title: 'PDF' }, + image: { paths: FilePaths, fill: '#EC4899', title: 'Image' }, + video: { paths: VideoPaths, fill: '#A855F7', title: 'Video' }, + document: { paths: TextPaths, fill: '#3B82F6', title: 'Document' }, + code: { paths: CodePaths, fill: '#F97316', title: 'Code' }, + spreadsheet: { paths: SheetPaths, fill: '#10B981', title: 'Spreadsheet' }, + audio: { paths: AudioPaths, fill: '#F59E0B', title: '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, +): LibraryFileType | null { + return type === 'artifact' ? null : TYPE_FILE_TYPE_MAP[type]; +} diff --git a/client/src/components/Library/libraryUtils.ts b/client/src/components/Library/libraryUtils.ts new file mode 100644 index 0000000..0b284c5 --- /dev/null +++ b/client/src/components/Library/libraryUtils.ts @@ -0,0 +1,164 @@ +import type { Option } from '~/common'; +import type { + LibraryFiltersState, + LibraryItem, + LibraryItemType, + LibrarySortOption, + LibraryTypeFilter, +} from '~/types/library'; + +/** Human-readable labels for each library item type. */ +export const LIBRARY_TYPE_LABELS: Record = { + pdf: 'PDF', + image: 'Image', + video: 'Video', + document: 'Document', + code: 'Code', + spreadsheet: 'Spreadsheet', + audio: 'Audio', + artifact: 'Artifact', +}; + +/** Sort option labels for the sort dropdown. */ +export const LIBRARY_SORT_LABELS: Record = { + newest: 'Newest first', + oldest: 'Oldest first', + name: 'Name (A–Z)', + size: '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_LABELS)); + +/** Dropdown options for the type filter control. */ +export const LIBRARY_TYPE_FILTER_OPTIONS: Option[] = [ + { value: 'all', label: 'All types' }, + ...LIBRARY_FILTER_TYPES.map((type) => ({ + value: type, + label: LIBRARY_TYPE_LABELS[type], + })), +]; + +/** Dropdown options for the sort control. */ +export const LIBRARY_SORT_OPTIONS: Option[] = ( + Object.entries(LIBRARY_SORT_LABELS) as [LibrarySortOption, string][] +).map(([value, label]) => ({ value, label })); + +/** 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); +} \ No newline at end of file diff --git a/client/src/components/Library/useMockLibraryData.ts b/client/src/components/Library/useMockLibraryData.ts new file mode 100644 index 0000000..f89498e --- /dev/null +++ b/client/src/components/Library/useMockLibraryData.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState } from 'react'; +import { mockLibraryResponse } from '~/data/mockLibraryData'; +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 { 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('Failed to connect to the library service. Please try again.'); + 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]); + + return { + items, + storage, + status, + errorMessage, + isLoading: status === 'loading', + refetch, + }; +} \ No newline at end of file diff --git a/client/src/data/mockLibraryData.ts b/client/src/data/mockLibraryData.ts new file mode 100644 index 0000000..e8fe8a5 --- /dev/null +++ b/client/src/data/mockLibraryData.ts @@ -0,0 +1,147 @@ +import type { LibraryItem, LibraryListResponse, LibraryStorageInfo } from '~/types/library'; + +/** Mock library items covering all supported types and both kinds. */ +export const mockLibraryItems: LibraryItem[] = [ + { + id: 'lib-001', + 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', + mimeType: 'application/pdf', + }, + { + id: 'lib-002', + 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', + mimeType: 'image/png', + }, + { + id: 'lib-003', + name: 'onboarding-demo.mp4', + type: 'video', + kind: 'file', + sizeBytes: 45_875_200, + createdAt: '2026-05-15T11:30:00.000Z', + description: 'Product onboarding walkthrough', + mimeType: 'video/mp4', + }, + { + id: 'lib-004', + name: 'Project Proposal.docx', + type: 'document', + kind: 'file', + sizeBytes: 524_288, + createdAt: '2026-04-20T16:45:00.000Z', + description: 'Client project proposal draft', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + id: 'lib-005', + 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', + mimeType: 'text/typescript', + }, + { + id: 'lib-006', + name: 'sales-data-2026.xlsx', + type: 'spreadsheet', + kind: 'file', + sizeBytes: 892_160, + createdAt: '2026-05-02T13:00:00.000Z', + description: 'Annual sales data export', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + { + id: 'lib-007', + name: 'meeting-recording.mp3', + type: 'audio', + kind: 'file', + sizeBytes: 8_388_608, + createdAt: '2026-03-18T10:20:00.000Z', + description: 'Team standup recording', + mimeType: 'audio/mpeg', + }, + { + id: 'lib-008', + 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', + mimeType: 'text/typescript', + }, + { + id: 'lib-009', + name: 'architecture-diagram.pdf', + type: 'pdf', + kind: 'file', + sizeBytes: 1_048_576, + createdAt: '2026-02-10T12:00:00.000Z', + description: 'System architecture overview', + mimeType: 'application/pdf', + }, + { + id: 'lib-010', + name: 'utils.py', + type: 'code', + kind: 'artifact', + sizeBytes: 6_200, + createdAt: '2026-06-07T14:30:00.000Z', + description: 'Python utility functions', + conversationId: 'conv-ghi789', + mimeType: 'text/x-python', + }, + { + id: 'lib-011', + name: 'team-photo.jpg', + type: 'image', + kind: 'file', + sizeBytes: 3_145_728, + createdAt: '2026-01-25T18:00:00.000Z', + description: 'Company team photo', + mimeType: 'image/jpeg', + }, + { + id: 'lib-012', + 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', + mimeType: '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 }, +}; \ No newline at end of file diff --git a/client/src/types/library.ts b/client/src/types/library.ts new file mode 100644 index 0000000..743a53b --- /dev/null +++ b/client/src/types/library.ts @@ -0,0 +1,58 @@ +/** 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: string; + name: string; + type: LibraryItemType; + kind: LibraryItemKind; + sizeBytes: number; + createdAt: string; + updatedAt?: string; + description?: string; + conversationId?: string; + mimeType?: string; +} + +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; +} \ No newline at end of file