(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 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}