Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 314 lines 10 kB view raw
1import { useState, useEffect } from "react"; 2import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3import { ArrowLeft, Edit2, Trash2, Plus, ExternalLink } from "lucide-react"; 4import { 5 getCollection, 6 getCollectionItems, 7 removeItemFromCollection, 8 deleteCollection, 9 resolveHandle, 10} from "../api/client"; 11import { useAuth } from "../context/AuthContext"; 12import CollectionModal from "../components/CollectionModal"; 13import CollectionIcon from "../components/CollectionIcon"; 14import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 15import BookmarkCard from "../components/BookmarkCard"; 16import ShareMenu from "../components/ShareMenu"; 17 18export default function CollectionDetail() { 19 const { rkey, handle, "*": wildcardPath } = useParams(); 20 const location = useLocation(); 21 const navigate = useNavigate(); 22 const { user } = useAuth(); 23 24 const [collection, setCollection] = useState(null); 25 const [items, setItems] = useState([]); 26 const [loading, setLoading] = useState(true); 27 const [error, setError] = useState(null); 28 const [isEditModalOpen, setIsEditModalOpen] = useState(false); 29 30 const [refreshTrigger, setRefreshTrigger] = useState(0); 31 32 const searchParams = new URLSearchParams(location.search); 33 const paramAuthorDid = searchParams.get("author"); 34 35 const isOwner = 36 user?.did && 37 (collection?.creator?.did === user.did || paramAuthorDid === user.did); 38 39 useEffect(() => { 40 let active = true; 41 42 const fetchContext = async () => { 43 if (active) { 44 setLoading(true); 45 setError(null); 46 } 47 48 try { 49 let targetUri = null; 50 let targetDid = paramAuthorDid || user?.did; 51 52 if (handle && rkey) { 53 try { 54 targetDid = await resolveHandle(handle); 55 if (!active) return; 56 targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 57 } catch (e) { 58 console.error("Failed to resolve handle", e); 59 if (active) setError("Could not resolve user handle"); 60 } 61 } else if (wildcardPath) { 62 targetUri = decodeURIComponent(wildcardPath); 63 } else if (rkey && targetDid) { 64 targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 65 } 66 67 if (!targetUri) { 68 if (active) { 69 if (!user && !handle && !paramAuthorDid) { 70 setError("Please log in to view your collections"); 71 } else if (!error) { 72 setError("Invalid collection URL"); 73 } 74 } 75 return; 76 } 77 78 if (!targetDid && targetUri.startsWith("at://")) { 79 const parts = targetUri.split("/"); 80 if (parts.length > 2) targetDid = parts[2]; 81 } 82 83 const collectionData = await getCollection(targetUri); 84 if (!active) return; 85 86 setCollection(collectionData); 87 88 const itemsData = await getCollectionItems(collectionData.uri); 89 if (!active) return; 90 91 setItems(itemsData || []); 92 } catch (err) { 93 console.error("Fetch failed:", err); 94 if (active) { 95 if ( 96 err.message.includes("404") || 97 err.message.includes("not found") 98 ) { 99 setError("Collection not found"); 100 } else { 101 setError(err.message || "Failed to load collection"); 102 } 103 } 104 } finally { 105 if (active) setLoading(false); 106 } 107 }; 108 109 fetchContext(); 110 111 return () => { 112 active = false; 113 }; 114 }, [ 115 paramAuthorDid, 116 user?.did, 117 handle, 118 rkey, 119 wildcardPath, 120 refreshTrigger, 121 error, 122 user, 123 ]); 124 125 const handleEditSuccess = () => { 126 setIsEditModalOpen(false); 127 setRefreshTrigger((v) => v + 1); 128 }; 129 130 const handleDeleteItem = async (itemUri) => { 131 if (!confirm("Remove this item from the collection?")) return; 132 try { 133 await removeItemFromCollection(itemUri); 134 setItems((prev) => prev.filter((i) => i.uri !== itemUri)); 135 } catch (err) { 136 console.error(err); 137 alert("Failed to remove item"); 138 } 139 }; 140 141 if (loading) { 142 return ( 143 <div className="feed-page"> 144 <div 145 style={{ 146 display: "flex", 147 justifyContent: "center", 148 padding: "60px 0", 149 }} 150 > 151 <div className="spinner"></div> 152 </div> 153 </div> 154 ); 155 } 156 157 if (error || !collection) { 158 return ( 159 <div className="feed-page"> 160 <div className="empty-state card"> 161 <div className="empty-state-icon"></div> 162 <h3 className="empty-state-title"> 163 {error || "Collection not found"} 164 </h3> 165 <button 166 onClick={() => navigate("/collections")} 167 className="btn btn-secondary" 168 style={{ marginTop: "16px" }} 169 > 170 Back to Collections 171 </button> 172 </div> 173 </div> 174 ); 175 } 176 177 return ( 178 <div className="feed-page"> 179 <Link to="/collections" className="back-link"> 180 <ArrowLeft size={18} /> 181 <span>Collections</span> 182 </Link> 183 184 <div className="collection-detail-header"> 185 <div className="collection-detail-icon"> 186 <CollectionIcon icon={collection.icon} size={28} /> 187 </div> 188 <div className="collection-detail-info"> 189 <h1 className="collection-detail-title">{collection.name}</h1> 190 {collection.description && ( 191 <p className="collection-detail-desc">{collection.description}</p> 192 )} 193 <div className="collection-detail-stats"> 194 <span> 195 {items.length} {items.length === 1 ? "item" : "items"} 196 </span> 197 <span>·</span> 198 <span> 199 Created {new Date(collection.createdAt).toLocaleDateString()} 200 </span> 201 </div> 202 </div> 203 <div className="collection-detail-actions"> 204 <ShareMenu 205 uri={collection.uri} 206 handle={collection.creator?.handle} 207 type="Collection" 208 text={`Check out this collection: ${collection.name}`} 209 /> 210 {isOwner && ( 211 <> 212 {collection.uri.includes("network.cosmik.collection") ? ( 213 <a 214 href={`https://semble.so/profile/${collection.creator?.handle || collection.creator?.did}/collections/${collection.uri.split("/").pop()}`} 215 target="_blank" 216 rel="noopener noreferrer" 217 className="collection-detail-edit btn btn-secondary btn-sm" 218 style={{ 219 textDecoration: "none", 220 display: "flex", 221 gap: "6px", 222 alignItems: "center", 223 }} 224 title="Manage on Semble" 225 > 226 <span>Manage on Semble</span> 227 <ExternalLink size={16} /> 228 </a> 229 ) : ( 230 <> 231 <button 232 onClick={() => setIsEditModalOpen(true)} 233 className="collection-detail-edit" 234 title="Edit Collection" 235 > 236 <Edit2 size={18} /> 237 </button> 238 <button 239 onClick={async () => { 240 if ( 241 confirm("Delete this collection and all its items?") 242 ) { 243 await deleteCollection(collection.uri); 244 navigate("/collections"); 245 } 246 }} 247 className="collection-detail-delete" 248 title="Delete Collection" 249 > 250 <Trash2 size={18} /> 251 </button> 252 </> 253 )} 254 </> 255 )} 256 </div> 257 </div> 258 259 <div className="feed-container"> 260 <div className="feed"> 261 {items.length === 0 ? ( 262 <div className="empty-state card" style={{ borderStyle: "dashed" }}> 263 <div className="empty-state-icon"> 264 <Plus size={32} /> 265 </div> 266 <h3 className="empty-state-title">Collection is empty</h3> 267 <p className="empty-state-text"> 268 {isOwner 269 ? 'Add items to this collection from your feed or bookmarks using the "Collect" button.' 270 : "This collection has no items yet."} 271 </p> 272 </div> 273 ) : ( 274 items.map((item) => ( 275 <div key={item.uri} className="collection-item-wrapper"> 276 {isOwner && 277 !collection.uri.includes("network.cosmik.collection") && ( 278 <button 279 onClick={() => handleDeleteItem(item.uri)} 280 className="collection-item-remove" 281 title="Remove from collection" 282 > 283 <Trash2 size={14} /> 284 </button> 285 )} 286 287 {item.annotation ? ( 288 <AnnotationCard annotation={item.annotation} /> 289 ) : item.highlight ? ( 290 <HighlightCard highlight={item.highlight} /> 291 ) : item.bookmark ? ( 292 <BookmarkCard bookmark={item.bookmark} /> 293 ) : ( 294 <div className="card" style={{ padding: "16px" }}> 295 <p className="text-secondary">Item could not be loaded</p> 296 </div> 297 )} 298 </div> 299 )) 300 )} 301 </div> 302 </div> 303 304 {isOwner && ( 305 <CollectionModal 306 isOpen={isEditModalOpen} 307 onClose={() => setIsEditModalOpen(false)} 308 onSuccess={handleEditSuccess} 309 collectionToEdit={collection} 310 /> 311 )} 312 </div> 313 ); 314}