Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 399 lines 17 kB view raw
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}