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