(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at frontend-rewrite 335 lines 14 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 { 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}