(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
Something went wrong. Try again.
JSX
at ui-refactor
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315import { useState, useEffect } from "react";import { useParams, useNavigate, Link, useLocation } from "react-router-dom";import { ArrowLeft, Edit2, Trash2, Plus, ExternalLink } from "lucide-react";import { getCollection, getCollectionItems, removeItemFromCollection, deleteCollection, resolveHandle,} from "../api/client";import { useAuth } from "../context/AuthContext";import CollectionModal from "../components/CollectionModal";import CollectionIcon from "../components/CollectionIcon";import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";import BookmarkCard from "../components/BookmarkCard";import ShareMenu from "../components/ShareMenu";
export default function CollectionDetail() { const { rkey, handle, "*": wildcardPath } = useParams(); const location = useLocation(); const navigate = useNavigate(); const { user } = useAuth();
const [collection, setCollection] = useState(null); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const searchParams = new URLSearchParams(location.search); const paramAuthorDid = searchParams.get("author");
const isOwner = user?.did && (collection?.creator?.did === user.did || paramAuthorDid === user.did);
useEffect(() => { let active = true;
const fetchContext = async () => { if (active) { setLoading(true); setError(null); }
try { let targetUri = null; let targetDid = paramAuthorDid || user?.did;
if (handle && rkey) { try { targetDid = await resolveHandle(handle); if (!active) return; targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; } catch (e) { console.error("Failed to resolve handle", e); if (active) setError("Could not resolve user handle"); } } else if (wildcardPath) { targetUri = decodeURIComponent(wildcardPath); } else if (rkey && targetDid) { targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; }
if (!targetUri) { if (active) { if (!user && !handle && !paramAuthorDid) { setError("Please log in to view your collections"); } else if (!error) { setError("Invalid collection URL"); } } return; }
if (!targetDid && targetUri.startsWith("at://")) { const parts = targetUri.split("/"); if (parts.length > 2) targetDid = parts[2]; }
const collectionData = await getCollection(targetUri); if (!active) return;
setCollection(collectionData);
const itemsData = await getCollectionItems(collectionData.uri); if (!active) return;
setItems(itemsData || []); } catch (err) { console.error("Fetch failed:", err); if (active) { if ( err.message.includes("404") || err.message.includes("not found") ) { setError("Collection not found"); } else { setError(err.message || "Failed to load collection"); } } } finally { if (active) setLoading(false); } };
fetchContext();
return () => { active = false; }; }, [ paramAuthorDid, user?.did, handle, rkey, wildcardPath, refreshTrigger, error, user, ]);
const handleEditSuccess = () => { setIsEditModalOpen(false); setRefreshTrigger((v) => v + 1); };
const handleDeleteItem = async (itemUri) => { if (!confirm("Remove this item from the collection?")) return; try { await removeItemFromCollection(itemUri); setItems((prev) => prev.filter((i) => i.uri !== itemUri)); } catch (err) { console.error(err); alert("Failed to remove item"); } };
if (loading) { return ( <div className="feed-page"> <div style={{ display: "flex", justifyContent: "center", padding: "60px 0", }} > <div className="spinner"></div> </div> </div> ); }
if (error || !collection) { return ( <div className="feed-page"> <div className="empty-state card"> <div className="empty-state-icon">⚠️</div> <h3 className="empty-state-title"> {error || "Collection not found"} </h3> <button onClick={() => navigate("/collections")} className="btn btn-secondary" style={{ marginTop: "16px" }} > Back to Collections </button> </div> </div> ); }
return ( <div className="feed-page"> <Link to="/collections" className="back-link"> <ArrowLeft size={18} /> <span>Collections</span> </Link>
<div className="collection-detail-header"> <div className="collection-detail-icon"> <CollectionIcon icon={collection.icon} size={28} /> </div> <div className="collection-detail-info"> <h1 className="collection-detail-title">{collection.name}</h1> {collection.description && ( <p className="collection-detail-desc">{collection.description}</p> )} <div className="collection-detail-stats"> <span> {items.length} {items.length === 1 ? "item" : "items"} </span> <span>·</span> <span> Created {new Date(collection.createdAt).toLocaleDateString()} </span> </div> </div> <div className="collection-detail-actions"> <ShareMenu uri={collection.uri} handle={collection.creator?.handle} type="Collection" text={`Check out this collection: ${collection.name}`} /> {isOwner && ( <> {collection.uri.includes("network.cosmik.collection") ? ( <a href={`https://semble.so/profile/${collection.creator?.handle || collection.creator?.did}/collections/${collection.uri.split("/").pop()}`} target="_blank" rel="noopener noreferrer" className="collection-detail-edit btn btn-secondary btn-sm" style={{ textDecoration: "none", display: "flex", gap: "6px", alignItems: "center", }} title="Manage on Semble" > <span>Manage on Semble</span> <ExternalLink size={16} /> </a> ) : ( <> <button onClick={() => setIsEditModalOpen(true)} className="collection-detail-edit" title="Edit Collection" > <Edit2 size={18} /> </button> <button onClick={async () => { if ( confirm("Delete this collection and all its items?") ) { await deleteCollection(collection.uri); navigate("/collections"); } }} className="collection-detail-delete" title="Delete Collection" > <Trash2 size={18} /> </button> </> )} </> )} </div> </div>
<div className="feed-container"> <div className="feed"> {items.length === 0 ? ( <div className="empty-state card" style={{ borderStyle: "dashed" }}> <div className="empty-state-icon"> <Plus size={32} /> </div> <h3 className="empty-state-title">Collection is empty</h3> <p className="empty-state-text"> {isOwner ? 'Add items to this collection from your feed or bookmarks using the "Collect" button.' : "This collection has no items yet."} </p> </div> ) : ( items.map((item) => ( <div key={item.uri} className="collection-item-wrapper"> {isOwner && !collection.uri.includes("network.cosmik.collection") && ( <button onClick={() => handleDeleteItem(item.uri)} className="collection-item-remove" title="Remove from collection" > <Trash2 size={14} /> </button> )}
{item.annotation ? ( <AnnotationCard annotation={item.annotation} /> ) : item.highlight ? ( <HighlightCard highlight={item.highlight} /> ) : item.bookmark ? ( <BookmarkCard bookmark={item.bookmark} /> ) : ( <div className="card" style={{ padding: "16px" }}> <p className="text-secondary">Item could not be loaded</p> </div> )} </div> )) )} </div> </div>
{isOwner && ( <CollectionModal isOpen={isEditModalOpen} onClose={() => setIsEditModalOpen(false)} onSuccess={handleEditSuccess} collectionToEdit={collection} /> )} </div> );}