Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect, useCallback } from "react";
2import {
3 X,
4 Plus,
5 Check,
6 Loader2,
7 ChevronRight,
8 FolderPlus,
9} from "lucide-react";
10import CollectionIcon from "../common/CollectionIcon";
11import { ICON_MAP } from "../common/iconMap";
12import EmojiPicker, { Theme } from "emoji-picker-react";
13import { useStore } from "@nanostores/react";
14import { $user } from "../../store/auth";
15import { $theme } from "../../store/theme";
16import {
17 getCollections,
18 addCollectionItem,
19 createCollection,
20 getCollectionsContaining,
21 type Collection,
22} from "../../api/client";
23
24interface AddToCollectionModalProps {
25 isOpen: boolean;
26 onClose: () => void;
27 annotationUri: string;
28}
29
30export default function AddToCollectionModal({
31 isOpen,
32 onClose,
33 annotationUri,
34}: AddToCollectionModalProps) {
35 const user = useStore($user);
36 const theme = useStore($theme);
37 const [collections, setCollections] = useState<Collection[]>([]);
38 const [loading, setLoading] = useState(true);
39 const [addingTo, setAddingTo] = useState<string | null>(null);
40 const [addedTo, setAddedTo] = useState<Set<string>>(new Set());
41 const [error, setError] = useState<string | null>(null);
42
43 const [showNewForm, setShowNewForm] = useState(false);
44 const [newName, setNewName] = useState("");
45 const [newDescription, setNewDescription] = useState("");
46 const [newIcon, setNewIcon] = useState("");
47 const [activeTab, setActiveTab] = useState<"icon" | "emoji">("icon");
48 const [creating, setCreating] = useState(false);
49
50 useEffect(() => {
51 if (isOpen) {
52 document.body.style.overflow = "hidden";
53 }
54 return () => {
55 document.body.style.overflow = "unset";
56 };
57 }, [isOpen]);
58
59 const loadCollections = useCallback(async () => {
60 if (!user) return;
61 try {
62 setLoading(true);
63 const data = await getCollections(user.did);
64 setCollections(data);
65 } catch (err) {
66 console.error(err);
67 setError("Failed to load collections");
68 } finally {
69 setLoading(false);
70 }
71 }, [user]);
72
73 useEffect(() => {
74 if (isOpen && user) {
75 loadCollections();
76 setError(null);
77 getCollectionsContaining(annotationUri).then((uris) => {
78 setAddedTo(new Set(uris));
79 });
80 }
81 }, [isOpen, user, loadCollections, annotationUri]);
82
83 const handleAdd = async (collectionUri: string) => {
84 if (addedTo.has(collectionUri)) return;
85
86 try {
87 setAddingTo(collectionUri);
88 await addCollectionItem(collectionUri, annotationUri);
89 setAddedTo((prev) => new Set([...prev, collectionUri]));
90 } catch (err) {
91 console.error(err);
92 setError("Failed to add to collection");
93 } finally {
94 setAddingTo(null);
95 }
96 };
97
98 const handleCreate = async (e: React.FormEvent) => {
99 e.preventDefault();
100 if (!newName.trim()) return;
101 try {
102 setCreating(true);
103 const iconValue = newIcon
104 ? ICON_MAP[newIcon]
105 ? `icon:${newIcon}`
106 : newIcon
107 : undefined;
108 const newCollection = await createCollection(
109 newName.trim(),
110 newDescription.trim() || undefined,
111 iconValue,
112 );
113 if (newCollection) {
114 setCollections((prev) => [newCollection, ...prev]);
115 setNewName("");
116 setNewDescription("");
117 setNewIcon("");
118 setShowNewForm(false);
119 }
120 } catch (err) {
121 console.error(err);
122 setError("Failed to create collection");
123 } finally {
124 setCreating(false);
125 }
126 };
127
128 if (!isOpen) return null;
129
130 return (
131 <div
132 className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
133 onClick={onClose}
134 >
135 <div
136 className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden"
137 onClick={(e) => e.stopPropagation()}
138 >
139 <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800">
140 <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white">
141 Add to Collection
142 </h2>
143 <button
144 onClick={onClose}
145 className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors"
146 >
147 <X size={20} />
148 </button>
149 </div>
150
151 <div className="px-6 pb-6 pt-4">
152 {loading ? (
153 <div className="text-center py-10">
154 <Loader2
155 size={32}
156 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-3"
157 />
158 <p className="text-surface-500 dark:text-surface-400 font-medium">
159 Loading collections...
160 </p>
161 </div>
162 ) : showNewForm ? (
163 <form onSubmit={handleCreate} className="space-y-4">
164 <div>
165 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
166 Collection name
167 </label>
168 <input
169 type="text"
170 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500"
171 value={newName}
172 onChange={(e) => setNewName(e.target.value)}
173 placeholder="My Collection"
174 autoFocus
175 />
176 </div>
177
178 <div>
179 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
180 Description (optional)
181 </label>
182 <textarea
183 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500 resize-none"
184 value={newDescription}
185 onChange={(e) => setNewDescription(e.target.value)}
186 placeholder="What's this collection about?"
187 rows={2}
188 />
189 </div>
190
191 <div>
192 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
193 Icon
194 </label>
195
196 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl">
197 <button
198 type="button"
199 onClick={() => setActiveTab("icon")}
200 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
201 activeTab === "icon"
202 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
203 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
204 }`}
205 >
206 Icons
207 </button>
208 <button
209 type="button"
210 onClick={() => setActiveTab("emoji")}
211 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
212 activeTab === "emoji"
213 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
214 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
215 }`}
216 >
217 Emojis
218 </button>
219 </div>
220
221 {activeTab === "icon" ? (
222 <div className="grid grid-cols-8 gap-1.5 max-h-60 overflow-y-auto p-2 bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 custom-scrollbar">
223 {Object.keys(ICON_MAP).map((iconName) => {
224 const isSelected = newIcon === iconName;
225 return (
226 <button
227 key={iconName}
228 type="button"
229 onClick={() => setNewIcon(isSelected ? "" : iconName)}
230 className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${
231 isSelected
232 ? "bg-primary-600 text-white"
233 : "hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-600 dark:text-surface-400"
234 }`}
235 title={iconName}
236 >
237 <CollectionIcon icon={`icon:${iconName}`} size={16} />
238 </button>
239 );
240 })}
241 </div>
242 ) : (
243 <div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
244 <EmojiPicker
245 className="custom-emoji-picker"
246 onEmojiClick={(emojiData) => setNewIcon(emojiData.emoji)}
247 autoFocusSearch={false}
248 width="100%"
249 height={300}
250 previewConfig={{ showPreview: false }}
251 skinTonesDisabled
252 lazyLoadEmojis
253 theme={
254 theme === "dark" ||
255 (theme === "system" &&
256 window.matchMedia("(prefers-color-scheme: dark)")
257 .matches)
258 ? (Theme.DARK as Theme)
259 : (Theme.LIGHT as Theme)
260 }
261 />
262 </div>
263 )}
264
265 {newIcon && (
266 <p className="mt-2 text-sm text-surface-600 dark:text-surface-300 flex items-center gap-2">
267 Selected:
268 <span className="inline-flex items-center justify-center w-8 h-8 bg-surface-100 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
269 <CollectionIcon
270 icon={ICON_MAP[newIcon] ? `icon:${newIcon}` : newIcon}
271 size={18}
272 />
273 </span>
274 </p>
275 )}
276 </div>
277
278 {error && (
279 <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg">
280 {error}
281 </div>
282 )}
283
284 <div className="flex gap-3 pt-2">
285 <button
286 type="button"
287 className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors"
288 onClick={() => {
289 setShowNewForm(false);
290 setNewDescription("");
291 setNewIcon("");
292 setError(null);
293 }}
294 >
295 Back
296 </button>
297 <button
298 type="submit"
299 className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
300 disabled={!newName.trim() || creating}
301 >
302 {creating && <Loader2 size={16} className="animate-spin" />}
303 {creating ? "Creating..." : "Create"}
304 </button>
305 </div>
306 </form>
307 ) : (
308 <div>
309 {error && (
310 <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg">
311 {error}
312 </div>
313 )}
314
315 <button
316 className="w-full flex items-center gap-4 p-4 bg-white dark:bg-surface-800 border-2 border-primary-100 dark:border-primary-900/50 hover:border-primary-300 dark:hover:border-primary-700 rounded-2xl shadow-sm hover:shadow-md transition-all group text-left mb-4"
317 onClick={() => setShowNewForm(true)}
318 >
319 <div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-full flex items-center justify-center text-primary-600 dark:text-primary-400 flex-shrink-0">
320 <FolderPlus size={20} />
321 </div>
322 <div className="flex-1 min-w-0">
323 <h3 className="font-bold text-surface-900 dark:text-white group-hover:text-primary-700 dark:group-hover:text-primary-400 transition-colors">
324 New Collection
325 </h3>
326 <span className="text-sm text-surface-500 dark:text-surface-400">
327 Create a new collection
328 </span>
329 </div>
330 <ChevronRight
331 size={20}
332 className="text-surface-300 dark:text-surface-600 group-hover:text-primary-500 dark:group-hover:text-primary-400"
333 />
334 </button>
335
336 {collections.length === 0 ? (
337 <div className="text-center py-6">
338 <p className="text-surface-500 dark:text-surface-400">
339 No collections yet
340 </p>
341 </div>
342 ) : (
343 <div className="space-y-2 max-h-[300px] overflow-y-auto">
344 {collections.map((col) => {
345 const isAdded = addedTo.has(col.uri);
346 const isAdding = addingTo === col.uri;
347
348 return (
349 <button
350 key={col.uri}
351 onClick={() => handleAdd(col.uri)}
352 disabled={isAdding || isAdded}
353 className="w-full flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800/50 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-colors text-left group disabled:opacity-70"
354 >
355 <div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-surface-700 rounded-full shadow-sm text-surface-600 dark:text-surface-300">
356 <CollectionIcon icon={col.icon} size={18} />
357 </div>
358 <div className="flex-1 min-w-0">
359 <h3 className="text-sm font-bold text-surface-900 dark:text-white">
360 {col.name}
361 </h3>
362 {col.description && (
363 <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1">
364 {col.description}
365 </p>
366 )}
367 </div>
368 {isAdding ? (
369 <Loader2
370 size={16}
371 className="animate-spin text-surface-400"
372 />
373 ) : isAdded ? (
374 <Check size={16} className="text-green-500" />
375 ) : (
376 <Plus
377 size={16}
378 className="text-surface-300 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300"
379 />
380 )}
381 </button>
382 );
383 })}
384 </div>
385 )}
386
387 <button
388 onClick={onClose}
389 className="w-full mt-4 py-3 bg-surface-900 dark:bg-white text-white dark:text-surface-900 font-semibold rounded-xl hover:bg-surface-800 dark:hover:bg-surface-100 transition-colors"
390 >
391 Done
392 </button>
393 </div>
394 )}
395 </div>
396 </div>
397 </div>
398 );
399}