A social knowledge tool for researchers built on ATProto
at development 9.2 kB view raw
1'use client'; 2 3import { useState, useMemo } from 'react'; 4import { 5 Stack, 6 TextInput, 7 Text, 8 Box, 9 Checkbox, 10 Badge, 11 Group, 12} from '@mantine/core'; 13import { ApiClient } from '@/api-client/ApiClient'; 14import { useCollectionSearch } from '@/hooks/useCollectionSearch'; 15import { CreateCollectionModal } from './CreateCollectionModal'; 16 17interface Collection { 18 id: string; 19 name: string; 20 description?: string; 21 cardCount?: number; 22 authorId: string; 23} 24 25interface CollectionSelectorProps { 26 apiClient: ApiClient; 27 userId?: string; 28 selectedCollectionIds: string[]; 29 onSelectionChange: (collectionIds: string[]) => void; 30 existingCollections?: Collection[]; 31 disabled?: boolean; 32 showCreateOption?: boolean; 33 placeholder?: string; 34 preSelectedCollectionId?: string | null; 35} 36 37export function CollectionSelector({ 38 apiClient, 39 userId, 40 selectedCollectionIds, 41 onSelectionChange, 42 existingCollections = [], 43 disabled = false, 44 showCreateOption = true, 45 placeholder = 'Search collections...', 46 preSelectedCollectionId, 47}: CollectionSelectorProps) { 48 const [createModalOpen, setCreateModalOpen] = useState(false); 49 50 // Get existing collection IDs for filtering 51 const existingCollectionIds = useMemo(() => { 52 return existingCollections.map((collection) => collection.id); 53 }, [existingCollections]); 54 55 // Collection search hook 56 const { 57 collections: allCollections, 58 loading: collectionsLoading, 59 searchText, 60 setSearchText, 61 handleSearchKeyPress, 62 loadCollections, 63 } = useCollectionSearch({ 64 apiClient, 65 initialLoad: true, 66 }); 67 68 // Filter out existing collections from search results 69 const availableCollections = useMemo(() => { 70 return allCollections.filter( 71 (collection) => !existingCollectionIds.includes(collection.id), 72 ); 73 }, [allCollections, existingCollectionIds]); 74 75 const handleCollectionToggle = (collectionId: string) => { 76 const newSelection = selectedCollectionIds.includes(collectionId) 77 ? selectedCollectionIds.filter((id) => id !== collectionId) 78 : [...selectedCollectionIds, collectionId]; 79 onSelectionChange(newSelection); 80 }; 81 82 const handleCreateCollection = (e?: React.MouseEvent) => { 83 e?.preventDefault(); 84 e?.stopPropagation(); 85 setCreateModalOpen(true); 86 }; 87 88 const handleCreateCollectionSuccess = ( 89 collectionId: string, 90 collectionName: string, 91 ) => { 92 onSelectionChange([...selectedCollectionIds, collectionId]); 93 loadCollections(searchText.trim() || undefined); 94 setCreateModalOpen(false); 95 }; 96 97 return ( 98 <> 99 <Stack gap="sm"> 100 <Text fw={500} size="sm"> 101 Collections 102 </Text> 103 104 {/* Show existing collections */} 105 {existingCollections.length > 0 && ( 106 <Box> 107 <Text size="xs" c="dimmed" mb="xs"> 108 Already in {existingCollections.length} collection 109 {existingCollections.length !== 1 && 's'}: 110 </Text> 111 <Group gap="xs"> 112 {existingCollections.map((collection) => ( 113 <Badge key={collection.id} variant="light" color="blue"> 114 {collection.name} 115 </Badge> 116 ))} 117 </Group> 118 </Box> 119 )} 120 121 <Text size="sm" c="dimmed"> 122 {existingCollections.length > 0 123 ? 'Add to additional collections (optional)' 124 : 'Select collections (optional)'} 125 </Text> 126 127 <TextInput 128 placeholder={placeholder} 129 value={searchText} 130 onChange={(e) => setSearchText(e.currentTarget.value)} 131 onKeyPress={handleSearchKeyPress} 132 disabled={disabled} 133 size="sm" 134 /> 135 136 <Box> 137 {availableCollections.length > 0 ? ( 138 <Stack gap={0}> 139 <Text size="xs" c="dimmed" mb="xs"> 140 {availableCollections.length} collection 141 {availableCollections.length !== 1 && 's'} found 142 </Text> 143 {searchText.trim() && showCreateOption && ( 144 <Box 145 p="sm" 146 style={{ 147 cursor: 'pointer', 148 backgroundColor: 'var(--mantine-color-green-0)', 149 borderRadius: '4px', 150 border: '1px solid var(--mantine-color-green-4)', 151 marginBottom: '4px', 152 }} 153 onClick={(e) => handleCreateCollection(e)} 154 > 155 <Group justify="space-between" align="center" wrap="nowrap"> 156 <Stack gap={2} style={{ flex: 1, minWidth: 0 }}> 157 <Text fw={500} size="sm" c="green.7"> 158 {`Create new collection "${searchText.trim()}"`} 159 </Text> 160 <Text size="xs" c="green.6"> 161 Click to create a new collection with this name 162 </Text> 163 </Stack> 164 <Text size="xs" c="green.6" fw={500}> 165 + New 166 </Text> 167 </Group> 168 </Box> 169 )} 170 {availableCollections.map((collection, index) => ( 171 <Box 172 key={collection.id} 173 p="sm" 174 style={{ 175 cursor: 'pointer', 176 backgroundColor: selectedCollectionIds.includes( 177 collection.id, 178 ) 179 ? 'var(--mantine-color-blue-0)' 180 : index % 2 === 0 181 ? 'var(--mantine-color-gray-0)' 182 : 'transparent', 183 borderRadius: '4px', 184 border: selectedCollectionIds.includes(collection.id) 185 ? '1px solid var(--mantine-color-blue-4)' 186 : '1px solid transparent', 187 }} 188 onClick={() => handleCollectionToggle(collection.id)} 189 > 190 <Group justify="space-between" align="center" wrap="nowrap"> 191 <Stack gap={2} style={{ flex: 1, minWidth: 0 }}> 192 <Group gap="xs" align="center"> 193 <Text fw={500} size="sm" truncate> 194 {collection.name} 195 </Text> 196 <Text size="xs" c="dimmed"> 197 {collection.cardCount} cards 198 </Text> 199 </Group> 200 {collection.description && ( 201 <Text size="xs" c="dimmed" lineClamp={1}> 202 {collection.description} 203 </Text> 204 )} 205 </Stack> 206 <Checkbox 207 checked={selectedCollectionIds.includes(collection.id)} 208 onChange={() => handleCollectionToggle(collection.id)} 209 disabled={disabled} 210 onClick={(e) => e.stopPropagation()} 211 size="sm" 212 /> 213 </Group> 214 </Box> 215 ))} 216 </Stack> 217 ) : searchText.trim() ? ( 218 <Stack gap="sm" py="md"> 219 {!collectionsLoading && ( 220 <Text size="sm" c="dimmed" ta="center"> 221 {`No collections found for "${searchText.trim()}"`} 222 </Text> 223 )} 224 {showCreateOption && ( 225 <Box 226 p="sm" 227 style={{ 228 cursor: 'pointer', 229 backgroundColor: 'var(--mantine-color-green-0)', 230 borderRadius: '4px', 231 border: '1px solid var(--mantine-color-green-4)', 232 }} 233 onClick={(e) => handleCreateCollection(e)} 234 > 235 <Group justify="space-between" align="center" wrap="nowrap"> 236 <Stack gap={2} style={{ flex: 1, minWidth: 0 }}> 237 <Text fw={500} size="sm" c="green.7"> 238 {`Create new collection "${searchText.trim()}"`} 239 </Text> 240 <Text size="xs" c="green.6"> 241 Click to create a new collection with this name 242 </Text> 243 </Stack> 244 <Text size="xs" c="green.6" fw={500}> 245 + New 246 </Text> 247 </Group> 248 </Box> 249 )} 250 </Stack> 251 ) : ( 252 <Text size="sm" c="dimmed" py="md" ta="center"> 253 No collections found. You can create collections from your 254 library. 255 </Text> 256 )} 257 </Box> 258 </Stack> 259 260 {showCreateOption && ( 261 <CreateCollectionModal 262 isOpen={createModalOpen} 263 onClose={() => setCreateModalOpen(false)} 264 onSuccess={handleCreateCollectionSuccess} 265 apiClient={apiClient} 266 initialName={searchText.trim()} 267 /> 268 )} 269 </> 270 ); 271}