Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 8.3 kB view raw
1import { useState, useEffect, useCallback } from "react"; 2import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3import { ArrowLeft, Edit2, Trash2, Plus } from "lucide-react"; 4import { 5 getCollections, 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 searchParams = new URLSearchParams(location.search); 31 const paramAuthorDid = searchParams.get("author"); 32 33 const isOwner = 34 user?.did && 35 (collection?.creator?.did === user.did || paramAuthorDid === user.did); 36 37 const fetchContext = useCallback(async () => { 38 try { 39 setLoading(true); 40 41 let targetUri = null; 42 let targetDid = paramAuthorDid || user?.did; 43 44 if (handle && rkey) { 45 try { 46 targetDid = await resolveHandle(handle); 47 targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 48 } catch (e) { 49 console.error("Failed to resolve handle", e); 50 } 51 } else if (wildcardPath) { 52 targetUri = decodeURIComponent(wildcardPath); 53 } else if (rkey && targetDid) { 54 targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 55 } 56 57 if (!targetUri) { 58 if (!user && !handle && !paramAuthorDid) { 59 setError("Please log in to view your collections"); 60 return; 61 } 62 setError("Invalid collection URL"); 63 return; 64 } 65 66 if (!targetDid && targetUri.startsWith("at://")) { 67 const parts = targetUri.split("/"); 68 if (parts.length > 2) targetDid = parts[2]; 69 } 70 71 if (!targetDid) { 72 setError("Could not determine collection owner"); 73 return; 74 } 75 76 const [cols, itemsData] = await Promise.all([ 77 getCollections(targetDid), 78 getCollectionItems(targetUri), 79 ]); 80 81 const found = 82 cols.items?.find((c) => c.uri === targetUri) || 83 cols.items?.find( 84 (c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()), 85 ); 86 87 if (!found) { 88 setError("Collection not found"); 89 return; 90 } 91 setCollection(found); 92 setItems(itemsData || []); 93 } catch (err) { 94 console.error(err); 95 setError("Failed to load collection"); 96 } finally { 97 setLoading(false); 98 } 99 }, [paramAuthorDid, user, handle, rkey, wildcardPath]); 100 101 useEffect(() => { 102 fetchContext(); 103 }, [fetchContext]); 104 105 const handleEditSuccess = () => { 106 fetchContext(); 107 setIsEditModalOpen(false); 108 }; 109 110 const handleDeleteItem = async (itemUri) => { 111 if (!confirm("Remove this item from the collection?")) return; 112 try { 113 await removeItemFromCollection(itemUri); 114 setItems((prev) => prev.filter((i) => i.uri !== itemUri)); 115 } catch (err) { 116 console.error(err); 117 alert("Failed to remove item"); 118 } 119 }; 120 121 if (loading) { 122 return ( 123 <div className="feed-page"> 124 <div 125 style={{ 126 display: "flex", 127 justifyContent: "center", 128 padding: "60px 0", 129 }} 130 > 131 <div className="spinner"></div> 132 </div> 133 </div> 134 ); 135 } 136 137 if (error || !collection) { 138 return ( 139 <div className="feed-page"> 140 <div className="empty-state card"> 141 <div className="empty-state-icon"></div> 142 <h3 className="empty-state-title"> 143 {error || "Collection not found"} 144 </h3> 145 <button 146 onClick={() => navigate("/collections")} 147 className="btn btn-secondary" 148 style={{ marginTop: "16px" }} 149 > 150 Back to Collections 151 </button> 152 </div> 153 </div> 154 ); 155 } 156 157 return ( 158 <div className="feed-page"> 159 <Link to="/collections" className="back-link"> 160 <ArrowLeft size={18} /> 161 <span>Collections</span> 162 </Link> 163 164 <div className="collection-detail-header"> 165 <div className="collection-detail-icon"> 166 <CollectionIcon icon={collection.icon} size={28} /> 167 </div> 168 <div className="collection-detail-info"> 169 <h1 className="collection-detail-title">{collection.name}</h1> 170 {collection.description && ( 171 <p className="collection-detail-desc">{collection.description}</p> 172 )} 173 <div className="collection-detail-stats"> 174 <span> 175 {items.length} {items.length === 1 ? "item" : "items"} 176 </span> 177 <span>·</span> 178 <span> 179 Created {new Date(collection.createdAt).toLocaleDateString()} 180 </span> 181 </div> 182 </div> 183 <div className="collection-detail-actions"> 184 <ShareMenu 185 uri={collection.uri} 186 handle={collection.creator?.handle} 187 type="Collection" 188 text={`Check out this collection: ${collection.name}`} 189 /> 190 {isOwner && ( 191 <> 192 <button 193 onClick={() => setIsEditModalOpen(true)} 194 className="collection-detail-edit" 195 title="Edit Collection" 196 > 197 <Edit2 size={18} /> 198 </button> 199 <button 200 onClick={async () => { 201 if (confirm("Delete this collection and all its items?")) { 202 await deleteCollection(collection.uri); 203 navigate("/collections"); 204 } 205 }} 206 className="collection-detail-delete" 207 title="Delete Collection" 208 > 209 <Trash2 size={18} /> 210 </button> 211 </> 212 )} 213 </div> 214 </div> 215 216 <div className="feed"> 217 {items.length === 0 ? ( 218 <div className="empty-state card" style={{ borderStyle: "dashed" }}> 219 <div className="empty-state-icon"> 220 <Plus size={32} /> 221 </div> 222 <h3 className="empty-state-title">Collection is empty</h3> 223 <p className="empty-state-text"> 224 {isOwner 225 ? 'Add items to this collection from your feed or bookmarks using the "Collect" button.' 226 : "This collection has no items yet."} 227 </p> 228 </div> 229 ) : ( 230 items.map((item) => ( 231 <div key={item.uri} className="collection-item-wrapper"> 232 {isOwner && ( 233 <button 234 onClick={() => handleDeleteItem(item.uri)} 235 className="collection-item-remove" 236 title="Remove from collection" 237 > 238 <Trash2 size={14} /> 239 </button> 240 )} 241 242 {item.annotation ? ( 243 <AnnotationCard annotation={item.annotation} /> 244 ) : item.highlight ? ( 245 <HighlightCard highlight={item.highlight} /> 246 ) : item.bookmark ? ( 247 <BookmarkCard bookmark={item.bookmark} /> 248 ) : ( 249 <div className="card" style={{ padding: "16px" }}> 250 <p className="text-secondary">Item could not be loaded</p> 251 </div> 252 )} 253 </div> 254 )) 255 )} 256 </div> 257 258 {isOwner && ( 259 <CollectionModal 260 isOpen={isEditModalOpen} 261 onClose={() => setIsEditModalOpen(false)} 262 onSuccess={handleEditSuccess} 263 collectionToEdit={collection} 264 /> 265 )} 266 </div> 267 ); 268}