Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}