Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 248 lines 8.9 kB view raw
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}