A social knowledge tool for researchers built on ATProto
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}