Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useState } from "react";
2import { Link } from "react-router-dom";
3import {
4 getCollection,
5 getCollectionItems,
6 deleteCollection,
7 removeCollectionItem,
8 resolveHandle,
9} from "../../api/client";
10import { Loader2, ArrowLeft, Trash2, Plus, ExternalLink } from "lucide-react";
11import CollectionIcon from "../../components/common/CollectionIcon";
12import ShareMenu from "../../components/modals/ShareMenu";
13import Card from "../../components/common/Card";
14import { useStore } from "@nanostores/react";
15import { $user } from "../../store/auth";
16import type { Collection, AnnotationItem } from "../../types";
17import EditCollectionModal from "../../components/modals/EditCollectionModal";
18import { Edit3 } from "lucide-react";
19
20interface CollectionDetailProps {
21 handle?: string;
22 rkey?: string;
23 uri?: string;
24}
25
26export default function CollectionDetail({
27 handle,
28 rkey,
29 uri,
30}: CollectionDetailProps) {
31 const user = useStore($user);
32 const [collection, setCollection] = useState<Collection | null>(null);
33 const [items, setItems] = useState<AnnotationItem[]>([]);
34 const [loading, setLoading] = useState(true);
35 const [error, setError] = useState<string | null>(null);
36 const [isEditModalOpen, setIsEditModalOpen] = useState(false);
37
38 useEffect(() => {
39 const loadData = async () => {
40 setLoading(true);
41 try {
42 let targetUri = uri;
43 if (!targetUri && handle && rkey) {
44 if (handle.startsWith("did:")) {
45 targetUri = `at://${handle}/at.margin.collection/${rkey}`;
46 } else {
47 const did = await resolveHandle(handle);
48 if (did) {
49 targetUri = `at://${did}/at.margin.collection/${rkey}`;
50 } else {
51 setError("Collection not found");
52 setLoading(false);
53 return;
54 }
55 }
56 }
57
58 if (targetUri) {
59 const col = await getCollection(targetUri);
60 if (col) {
61 setCollection(col);
62 const colItems = await getCollectionItems(col.uri);
63 setItems(colItems.filter((i) => i && i.uri));
64 } else {
65 setError("Collection not found");
66 }
67 }
68 } catch {
69 setError("Failed to load collection");
70 } finally {
71 setLoading(false);
72 }
73 };
74
75 loadData();
76 }, [handle, rkey, uri]);
77
78 const handleDelete = async () => {
79 if (!collection) return;
80 if (window.confirm("Delete this collection?")) {
81 await deleteCollection(collection.id);
82 window.location.href = "/collections";
83 }
84 };
85
86 const handleRemoveItem = async (item: AnnotationItem) => {
87 if (!item.collectionItemUri) return;
88 if (!window.confirm("Remove from collection?")) return;
89 const success = await removeCollectionItem(item.collectionItemUri);
90 if (success) {
91 setItems((prev) =>
92 prev.filter((i) => i.collectionItemUri !== item.collectionItemUri),
93 );
94 }
95 };
96
97 if (loading) {
98 return (
99 <div className="flex justify-center py-20">
100 <Loader2
101 className="animate-spin text-primary-600 dark:text-primary-400"
102 size={32}
103 />
104 </div>
105 );
106 }
107
108 if (error || !collection) {
109 return (
110 <div className="text-center py-20 text-red-500 dark:text-red-400">
111 {error || "Collection not found"}
112 </div>
113 );
114 }
115
116 const isOwner = user?.did === collection.creator?.did;
117 const isSemble = collection.uri?.includes("network.cosmik");
118
119 const sembleUrl = (() => {
120 if (!isSemble) return "";
121 const parts = collection.uri.split("/");
122 const rk = parts[parts.length - 1];
123 const h = collection.creator?.handle || "";
124 return `https://semble.so/profile/${h}/collections/${rk}`;
125 })();
126
127 return (
128 <div className="animate-fade-in max-w-2xl mx-auto">
129 <a
130 href="/collections"
131 className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white mb-4 transition-colors"
132 >
133 <ArrowLeft size={16} />
134 Collections
135 </a>
136
137 <div className="bg-white dark:bg-surface-900 rounded-xl p-4 ring-1 ring-black/5 dark:ring-white/5 mb-4">
138 <div className="flex items-start gap-3">
139 <div className="p-2 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-lg">
140 <CollectionIcon icon={collection.icon} size={24} />
141 </div>
142 <div className="flex-1 min-w-0">
143 <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate">
144 {collection.name}
145 </h1>
146 {collection.description && (
147 <p className="text-surface-600 dark:text-surface-300 text-sm mt-1">
148 {collection.description}
149 </p>
150 )}
151 <div className="flex items-center gap-2 mt-2 text-xs text-surface-500 dark:text-surface-400">
152 <span className="font-medium bg-surface-100 dark:bg-surface-800 px-2 py-0.5 rounded">
153 {items.length} items
154 </span>
155 <span>
156 by{" "}
157 <Link
158 to={`/profile/${collection.creator?.did}`}
159 className="hover:text-primary-600 dark:hover:text-primary-400 hover:underline transition-colors"
160 >
161 {collection.creator?.displayName ||
162 collection.creator?.handle}
163 </Link>
164 </span>
165 </div>
166 </div>
167 <div className="flex items-center gap-1">
168 <ShareMenu
169 uri={collection.uri}
170 handle={collection.creator?.handle}
171 type="Collection"
172 text={collection.name}
173 />
174 {isOwner && !isSemble && (
175 <>
176 <button
177 onClick={() => setIsEditModalOpen(true)}
178 className="p-2 text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg transition-colors"
179 title="Edit collection"
180 >
181 <Edit3 size={18} />
182 </button>
183 <button
184 onClick={handleDelete}
185 className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
186 title="Delete collection"
187 >
188 <Trash2 size={18} />
189 </button>
190 </>
191 )}
192 {isSemble && (
193 <a
194 href={sembleUrl}
195 target="_blank"
196 rel="noopener noreferrer"
197 className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
198 >
199 <img src="/semble-logo.svg" alt="" className="w-3.5 h-3.5" />
200 View in Semble
201 <ExternalLink size={12} />
202 </a>
203 )}
204 </div>
205 </div>
206 </div>
207
208 <EditCollectionModal
209 isOpen={isEditModalOpen}
210 onClose={() => setIsEditModalOpen(false)}
211 collection={collection}
212 onUpdate={(updated) =>
213 setCollection({
214 ...updated,
215 creator: updated.creator || collection.creator,
216 })
217 }
218 />
219
220 <div className="space-y-2">
221 {items.length === 0 ? (
222 <div className="text-center py-12 text-surface-500 dark:text-surface-400 bg-surface-50 dark:bg-surface-800/50 rounded-xl border border-dashed border-surface-200 dark:border-surface-700">
223 <Plus
224 size={28}
225 className="mx-auto mb-2 text-surface-300 dark:text-surface-600"
226 />
227 <p className="text-sm">Collection is empty</p>
228 </div>
229 ) : (
230 items.map((item) => (
231 <div key={item.uri} className="relative group">
232 <Card item={item} hideShare />
233 {isOwner && !isSemble && item.collectionItemUri && (
234 <button
235 className="absolute top-3 right-3 p-1.5 bg-white/90 dark:bg-surface-800/90 backdrop-blur text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 rounded-lg shadow-sm transition-all"
236 onClick={() => handleRemoveItem(item)}
237 title="Remove from collection"
238 >
239 <Trash2 size={16} />
240 </button>
241 )}
242 </div>
243 ))
244 )}
245 </div>
246 </div>
247 );
248}