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

Configure Feed

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

at main 449 lines 18 kB view raw
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}