(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, useRef } from "react";
2import { useTranslation } from "react-i18next";
3import {
4 X,
5 Plus,
6 Check,
7 Loader2,
8 ChevronRight,
9 FolderPlus,
10} from "lucide-react";
11import CollectionIcon from "../common/CollectionIcon";
12import { ICON_MAP } from "../common/iconMap";
13import { Theme } from "emoji-picker-react";
14const EmojiPicker = React.lazy(() => import("emoji-picker-react"));
15import { useStore } from "@nanostores/react";
16import { $user } from "../../store/auth";
17import { $theme } from "../../store/theme";
18import { analytics } from "../../lib/analytics";
19import {
20 getCollections,
21 addCollectionItem,
22 createCollection,
23 getCollectionsContaining,
24 type Collection,
25} from "../../api/client";
26
27interface AddToCollectionModalProps {
28 isOpen: boolean;
29 onClose: () => void;
30 annotationUri: string;
31}
32
33export default function AddToCollectionModal({
34 isOpen,
35 onClose,
36 annotationUri,
37}: AddToCollectionModalProps) {
38 const { t } = useTranslation();
39 const user = useStore($user);
40 const theme = useStore($theme);
41 const [collections, setCollections] = useState<Collection[]>([]);
42 const [loading, setLoading] = useState(true);
43 const [addingTo, setAddingTo] = useState<string | null>(null);
44 const [addedTo, setAddedTo] = useState<Set<string>>(new Set());
45 const [error, setError] = useState<string | null>(null);
46
47 const sheetRef = useRef<HTMLDivElement>(null);
48 const dragStartY = useRef(0);
49 const dragCurrentY = useRef(0);
50
51 const handleTouchStart = (e: React.TouchEvent) => {
52 dragStartY.current = e.touches[0].clientY;
53 if (sheetRef.current) sheetRef.current.style.transition = "none";
54 };
55
56 const handleTouchMove = (e: React.TouchEvent) => {
57 const delta = e.touches[0].clientY - dragStartY.current;
58 dragCurrentY.current = delta;
59 if (delta > 0 && sheetRef.current) {
60 sheetRef.current.style.transform = `translateY(${delta}px)`;
61 }
62 };
63
64 const handleTouchEnd = () => {
65 if (sheetRef.current) {
66 sheetRef.current.style.transition = "transform 0.3s ease";
67 if (dragCurrentY.current > 100) {
68 sheetRef.current.style.transform = "translateY(100%)";
69 setTimeout(onClose, 300);
70 } else {
71 sheetRef.current.style.transform = "translateY(0)";
72 }
73 }
74 dragCurrentY.current = 0;
75 };
76
77 const [showNewForm, setShowNewForm] = useState(false);
78 const [newName, setNewName] = useState("");
79 const [newDescription, setNewDescription] = useState("");
80 const [newIcon, setNewIcon] = useState("");
81 const [activeTab, setActiveTab] = useState<"icon" | "emoji">("icon");
82 const [creating, setCreating] = useState(false);
83
84 useEffect(() => {
85 if (isOpen) {
86 document.body.style.overflow = "hidden";
87 }
88 return () => {
89 document.body.style.overflow = "unset";
90 };
91 }, [isOpen]);
92
93 const loadCollections = useCallback(async () => {
94 if (!user) return;
95 try {
96 setLoading(true);
97 const data = await getCollections(user.did);
98 setCollections(data);
99 } catch (err) {
100 console.error(err);
101 setError(t("addToCollection.failedLoad"));
102 } finally {
103 setLoading(false);
104 }
105 }, [user, t]);
106
107 useEffect(() => {
108 if (isOpen && user) {
109 loadCollections();
110 setError(null);
111 getCollectionsContaining(annotationUri).then((uris) => {
112 setAddedTo(new Set(uris));
113 });
114 }
115 }, [isOpen, user, loadCollections, annotationUri]);
116
117 const handleAdd = async (collectionUri: string) => {
118 if (addedTo.has(collectionUri)) return;
119
120 try {
121 setAddingTo(collectionUri);
122 await addCollectionItem(collectionUri, annotationUri);
123 setAddedTo((prev) => new Set([...prev, collectionUri]));
124 analytics.capture("item_added_to_collection");
125 } catch (err) {
126 console.error(err);
127 setError(t("addToCollection.failedAdd"));
128 } finally {
129 setAddingTo(null);
130 }
131 };
132
133 const handleCreate = async (e: React.FormEvent) => {
134 e.preventDefault();
135 if (!newName.trim()) return;
136 try {
137 setCreating(true);
138 const iconValue = newIcon
139 ? ICON_MAP[newIcon]
140 ? `icon:${newIcon}`
141 : newIcon
142 : undefined;
143 const newCollection = await createCollection(
144 newName.trim(),
145 newDescription.trim() || undefined,
146 iconValue,
147 );
148 if (newCollection) {
149 setCollections((prev) => [newCollection, ...prev]);
150 setNewName("");
151 setNewDescription("");
152 setNewIcon("");
153 setShowNewForm(false);
154 }
155 } catch (err) {
156 console.error(err);
157 setError(t("addToCollection.failedCreate"));
158 } finally {
159 setCreating(false);
160 }
161 };
162
163 if (!isOpen) return null;
164
165 return (
166 <div
167 className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center sm:p-4 bg-black/40 backdrop-blur-sm animate-fade-in"
168 onClick={onClose}
169 >
170 <div
171 ref={sheetRef}
172 className="w-full sm:max-w-md bg-white dark:bg-surface-900 rounded-t-3xl sm:rounded-3xl shadow-2xl flex flex-col animate-slide-up border border-surface-200 dark:border-surface-700 border-b-0 sm:border-b"
173 style={{
174 paddingBottom: "env(safe-area-inset-bottom)",
175 maxHeight: "90dvh",
176 }}
177 onClick={(e) => e.stopPropagation()}
178 >
179 <div
180 className="flex justify-center pt-3 pb-1 sm:hidden shrink-0 cursor-grab active:cursor-grabbing touch-none"
181 onTouchStart={handleTouchStart}
182 onTouchMove={handleTouchMove}
183 onTouchEnd={handleTouchEnd}
184 >
185 <div className="w-8 h-1 bg-surface-200 dark:bg-surface-700 rounded-full" />
186 </div>
187
188 <div className="px-4 sm:px-4 py-3 flex justify-between items-center border-b border-surface-100 dark:border-surface-800 shrink-0">
189 <h2 className="text-lg font-display font-bold text-surface-900 dark:text-white">
190 {t("addToCollection.title")}
191 </h2>
192 <button
193 onClick={onClose}
194 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"
195 >
196 <X size={18} />
197 </button>
198 </div>
199
200 <div className="px-4 sm:px-6 pb-4 pt-4 overflow-y-auto flex-1">
201 {loading ? (
202 <div className="text-center py-10">
203 <Loader2
204 size={32}
205 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-3"
206 />
207 <p className="text-surface-500 dark:text-surface-400 font-medium">
208 {t("addToCollection.loading")}
209 </p>
210 </div>
211 ) : showNewForm ? (
212 <form onSubmit={handleCreate} className="space-y-4">
213 <div>
214 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
215 {t("addToCollection.collectionNameLabel")}
216 </label>
217 <input
218 type="text"
219 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"
220 value={newName}
221 onChange={(e) => setNewName(e.target.value)}
222 placeholder={t("addToCollection.namePlaceholder")}
223 autoFocus
224 />
225 </div>
226
227 <div>
228 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
229 {t("addToCollection.descriptionLabel")}
230 </label>
231 <textarea
232 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"
233 value={newDescription}
234 onChange={(e) => setNewDescription(e.target.value)}
235 placeholder={t("addToCollection.descriptionPlaceholder")}
236 rows={2}
237 />
238 </div>
239
240 <div>
241 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
242 {t("addToCollection.iconLabel")}
243 </label>
244
245 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl">
246 <button
247 type="button"
248 onClick={() => setActiveTab("icon")}
249 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
250 activeTab === "icon"
251 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
252 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
253 }`}
254 >
255 {t("addToCollection.iconsTab")}
256 </button>
257 <button
258 type="button"
259 onClick={() => setActiveTab("emoji")}
260 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
261 activeTab === "emoji"
262 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
263 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
264 }`}
265 >
266 {t("addToCollection.emojisTab")}
267 </button>
268 </div>
269
270 {activeTab === "icon" ? (
271 <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">
272 {Object.keys(ICON_MAP).map((iconName) => {
273 const isSelected = newIcon === iconName;
274 return (
275 <button
276 key={iconName}
277 type="button"
278 onClick={() => setNewIcon(isSelected ? "" : iconName)}
279 className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${
280 isSelected
281 ? "bg-primary-600 text-white"
282 : "hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-600 dark:text-surface-400"
283 }`}
284 title={iconName}
285 >
286 <CollectionIcon icon={`icon:${iconName}`} size={16} />
287 </button>
288 );
289 })}
290 </div>
291 ) : (
292 <div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
293 <React.Suspense
294 fallback={
295 <div className="flex items-center justify-center h-[300px]">
296 <Loader2
297 className="animate-spin text-surface-400"
298 size={24}
299 />
300 </div>
301 }
302 >
303 <EmojiPicker
304 className="custom-emoji-picker"
305 onEmojiClick={(emojiData) =>
306 setNewIcon(emojiData.emoji)
307 }
308 autoFocusSearch={false}
309 width="100%"
310 height={300}
311 previewConfig={{ showPreview: false }}
312 skinTonesDisabled
313 lazyLoadEmojis
314 theme={
315 theme === "dark" ||
316 (theme === "system" &&
317 window.matchMedia("(prefers-color-scheme: dark)")
318 .matches)
319 ? (Theme.DARK as Theme)
320 : (Theme.LIGHT as Theme)
321 }
322 />
323 </React.Suspense>
324 </div>
325 )}
326
327 {newIcon && (
328 <p className="mt-2 text-sm text-surface-600 dark:text-surface-300 flex items-center gap-2">
329 {t("addToCollection.selected")}
330 <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">
331 <CollectionIcon
332 icon={ICON_MAP[newIcon] ? `icon:${newIcon}` : newIcon}
333 size={18}
334 />
335 </span>
336 </p>
337 )}
338 </div>
339
340 {error && (
341 <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg">
342 {error}
343 </div>
344 )}
345
346 <div className="flex gap-2 pt-2">
347 <button
348 type="button"
349 className="flex-1 py-2.5 text-sm font-medium text-surface-600 dark:text-surface-300 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-xl transition-colors"
350 onClick={() => {
351 setShowNewForm(false);
352 setNewDescription("");
353 setNewIcon("");
354 setError(null);
355 }}
356 >
357 {t("addToCollection.back")}
358 </button>
359 <button
360 type="submit"
361 className="flex-1 py-2.5 text-sm bg-primary-600 text-white font-medium rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
362 disabled={!newName.trim() || creating}
363 >
364 {creating && <Loader2 size={14} className="animate-spin" />}
365 {creating
366 ? t("addToCollection.creating")
367 : t("addToCollection.create")}
368 </button>
369 </div>
370 </form>
371 ) : (
372 <div className="-mx-4 sm:mx-0">
373 {error && (
374 <div className="mx-4 sm:mx-0 mb-2 p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg">
375 {error}
376 </div>
377 )}
378
379 <button
380 className="w-full flex items-center gap-3 px-4 sm:px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg text-primary-600 dark:text-primary-400 hover:bg-surface-50 dark:hover:bg-surface-800"
381 onClick={() => setShowNewForm(true)}
382 >
383 <span className="flex items-center justify-center w-5 h-5 text-primary-500 dark:text-primary-400">
384 <FolderPlus size={16} />
385 </span>
386 <span className="flex-1 text-left">
387 {t("addToCollection.newCollectionButton")}
388 </span>
389 <ChevronRight
390 size={14}
391 className="text-surface-300 dark:text-surface-600"
392 />
393 </button>
394
395 <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-4 sm:mx-2" />
396
397 {collections.length === 0 ? (
398 <div className="text-center py-6 px-4">
399 <p className="text-sm text-surface-400 dark:text-surface-500">
400 {t("addToCollection.none")}
401 </p>
402 </div>
403 ) : (
404 <div className="overflow-y-auto max-h-[50vh] sm:max-h-[300px]">
405 {collections.map((col) => {
406 const isAdded = addedTo.has(col.uri);
407 const isAdding = addingTo === col.uri;
408
409 return (
410 <button
411 key={col.uri}
412 onClick={() => handleAdd(col.uri)}
413 disabled={isAdding || isAdded}
414 className="w-full flex items-center gap-3 px-4 sm:px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800 disabled:opacity-60"
415 >
416 <span className="flex items-center justify-center w-5 h-5 text-surface-400 dark:text-surface-500">
417 <CollectionIcon icon={col.icon} size={16} />
418 </span>
419 <span className="flex-1 text-left truncate">
420 {col.name}
421 </span>
422 {isAdding ? (
423 <Loader2
424 size={15}
425 className="animate-spin text-surface-400 shrink-0"
426 />
427 ) : isAdded ? (
428 <Check
429 size={15}
430 className="text-green-500 shrink-0"
431 />
432 ) : (
433 <Plus
434 size={15}
435 className="text-surface-300 dark:text-surface-600 shrink-0"
436 />
437 )}
438 </button>
439 );
440 })}
441 </div>
442 )}
443 </div>
444 )}
445 </div>
446 </div>
447 </div>
448 );
449}