Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 300 lines 13 kB view raw
1import React, { useEffect, useState } from "react"; 2import { 3 getCollections, 4 createCollection, 5 deleteCollection, 6} from "../../api/client"; 7import { Plus, Folder, Trash2, X } from "lucide-react"; 8import CollectionIcon from "../../components/common/CollectionIcon"; 9import { ICON_MAP } from "../../components/common/iconMap"; 10import { useStore } from "@nanostores/react"; 11import { $user } from "../../store/auth"; 12import EmojiPicker, { Theme } from "emoji-picker-react"; 13import { $theme } from "../../store/theme"; 14import type { Collection } from "../../types"; 15import { formatDistanceToNow } from "date-fns"; 16import { clsx } from "clsx"; 17import { Button, Input, EmptyState, Skeleton } from "../../components/ui"; 18 19export default function Collections() { 20 const user = useStore($user); 21 const theme = useStore($theme); 22 const [collections, setCollections] = useState<Collection[]>([]); 23 const [loading, setLoading] = useState(true); 24 const [showCreateModal, setShowCreateModal] = useState(false); 25 const [newItemName, setNewItemName] = useState(""); 26 const [newItemDesc, setNewItemDesc] = useState(""); 27 const [newItemIcon, setNewItemIcon] = useState("folder"); 28 const [activeTab, setActiveTab] = useState<"icon" | "emoji">("icon"); 29 const [creating, setCreating] = useState(false); 30 31 const fetchCollections = async () => { 32 try { 33 setLoading(true); 34 const data = await getCollections(); 35 setCollections(data); 36 } catch (error) { 37 console.error("Failed to load collections:", error); 38 } finally { 39 setLoading(false); 40 } 41 }; 42 43 useEffect(() => { 44 fetchCollections(); 45 }, []); 46 47 const handleCreate = async (e: React.FormEvent) => { 48 e.preventDefault(); 49 if (!newItemName.trim()) return; 50 51 setCreating(true); 52 const finalIcon = ICON_MAP[newItemIcon] 53 ? `icon:${newItemIcon}` 54 : newItemIcon; 55 56 const res = await createCollection(newItemName, newItemDesc, finalIcon); 57 if (res) { 58 setCollections([res, ...collections]); 59 setShowCreateModal(false); 60 setNewItemName(""); 61 setNewItemDesc(""); 62 setNewItemIcon("folder"); 63 setActiveTab("icon"); 64 fetchCollections(); 65 } 66 setCreating(false); 67 }; 68 69 const handleDelete = async (id: string, e: React.MouseEvent) => { 70 e.preventDefault(); 71 if (window.confirm("Delete this collection?")) { 72 const success = await deleteCollection(id); 73 if (success) { 74 setCollections((prev) => prev.filter((c) => c.id !== id)); 75 } 76 } 77 }; 78 79 if (loading) { 80 return ( 81 <div className="max-w-2xl mx-auto animate-fade-in"> 82 <div className="flex items-center justify-between mb-6"> 83 <div> 84 <Skeleton width="180px" className="h-8 mb-2" /> 85 <Skeleton width="240px" className="h-4" /> 86 </div> 87 <Skeleton width="90px" className="h-10 rounded-lg" /> 88 </div> 89 <div className="space-y-2"> 90 {[1, 2, 3].map((i) => ( 91 <div key={i} className="card p-4 flex gap-3 items-center"> 92 <Skeleton className="w-10 h-10 rounded-lg" /> 93 <div className="flex-1 space-y-2"> 94 <Skeleton width="50%" /> 95 <Skeleton width="30%" className="h-3" /> 96 </div> 97 </div> 98 ))} 99 </div> 100 </div> 101 ); 102 } 103 104 return ( 105 <div className="max-w-2xl mx-auto animate-slide-up"> 106 <div className="flex items-center justify-between mb-6"> 107 <div> 108 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white"> 109 Collections 110 </h1> 111 <p className="text-surface-500 dark:text-surface-400 mt-1"> 112 Organize your annotations and highlights 113 </p> 114 </div> 115 <Button 116 onClick={() => setShowCreateModal(true)} 117 icon={<Plus size={16} />} 118 > 119 New 120 </Button> 121 </div> 122 123 {collections.length === 0 ? ( 124 <EmptyState 125 icon={<Folder size={48} />} 126 title="No collections yet" 127 message="Create a collection to organize your highlights and annotations." 128 action={{ 129 label: "Create collection", 130 onClick: () => setShowCreateModal(true), 131 }} 132 /> 133 ) : ( 134 <div className="space-y-2"> 135 {collections 136 .filter((c) => c && c.id && c.name) 137 .map((collection) => ( 138 <a 139 key={collection.id} 140 href={`/${collection.creator?.handle || user?.handle}/collection/${(collection.uri || "").split("/").pop()}`} 141 className="group card p-4 hover:ring-primary-300 dark:hover:ring-primary-600 transition-all flex items-center gap-4" 142 > 143 <div className="w-10 h-10 flex items-center justify-center shrink-0 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl"> 144 <CollectionIcon icon={collection.icon} size={20} /> 145 </div> 146 <div className="flex-1 min-w-0"> 147 <h3 className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 148 {collection.name} 149 </h3> 150 <p className="text-sm text-surface-500 dark:text-surface-400"> 151 {collection.itemCount}{" "} 152 {collection.itemCount === 1 ? "item" : "items"} 153 {collection.createdAt && 154 ` · ${formatDistanceToNow(new Date(collection.createdAt), { addSuffix: true })}`} 155 </p> 156 </div> 157 {!collection.uri.includes("network.cosmik") && ( 158 <button 159 onClick={(e) => handleDelete(collection.id, e)} 160 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-all opacity-0 group-hover:opacity-100" 161 > 162 <Trash2 size={18} /> 163 </button> 164 )} 165 </a> 166 ))} 167 </div> 168 )} 169 170 {showCreateModal && ( 171 <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"> 172 <div className="bg-white dark:bg-surface-900 rounded-2xl shadow-2xl max-w-md w-full animate-scale-in ring-1 ring-black/5 dark:ring-white/10"> 173 <div className="flex items-center justify-between p-5 border-b border-surface-100 dark:border-surface-800"> 174 <h2 className="text-xl font-bold text-surface-900 dark:text-white"> 175 New Collection 176 </h2> 177 <button 178 onClick={() => setShowCreateModal(false)} 179 className="p-2 text-surface-400 dark:text-surface-500 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors" 180 > 181 <X size={18} /> 182 </button> 183 </div> 184 <form onSubmit={handleCreate} className="p-5"> 185 <div className="mb-4"> 186 <Input 187 label="Name" 188 value={newItemName} 189 onChange={(e) => setNewItemName(e.target.value)} 190 placeholder="e.g. Design Inspiration" 191 autoFocus 192 required 193 /> 194 </div> 195 <div className="mb-4"> 196 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 197 Icon 198 </label> 199 200 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl"> 201 <button 202 type="button" 203 onClick={() => setActiveTab("icon")} 204 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${ 205 activeTab === "icon" 206 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 207 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 208 }`} 209 > 210 Icons 211 </button> 212 <button 213 type="button" 214 onClick={() => setActiveTab("emoji")} 215 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${ 216 activeTab === "emoji" 217 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 218 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200" 219 }`} 220 > 221 Emojis 222 </button> 223 </div> 224 225 {activeTab === "icon" ? ( 226 <div className="grid grid-cols-7 gap-1.5 p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl max-h-48 overflow-y-auto custom-scrollbar"> 227 {Object.keys(ICON_MAP).map((key) => { 228 const Icon = ICON_MAP[key]; 229 return ( 230 <button 231 key={key} 232 type="button" 233 onClick={() => setNewItemIcon(key)} 234 className={clsx( 235 "p-2 rounded-lg flex items-center justify-center transition-all", 236 newItemIcon === key 237 ? "bg-primary-100 dark:bg-primary-900/50 text-primary-600 dark:text-primary-400 ring-2 ring-primary-500" 238 : "hover:bg-surface-100 dark:hover:bg-surface-700 text-surface-500 dark:text-surface-400", 239 )} 240 > 241 <Icon size={18} /> 242 </button> 243 ); 244 })} 245 </div> 246 ) : ( 247 <div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden"> 248 <EmojiPicker 249 className="custom-emoji-picker" 250 onEmojiClick={(emojiData) => 251 setNewItemIcon(emojiData.emoji) 252 } 253 autoFocusSearch={false} 254 width="100%" 255 height={300} 256 previewConfig={{ showPreview: false }} 257 skinTonesDisabled 258 lazyLoadEmojis 259 theme={ 260 theme === "dark" || 261 (theme === "system" && 262 window.matchMedia("(prefers-color-scheme: dark)") 263 .matches) 264 ? (Theme.DARK as Theme) 265 : (Theme.LIGHT as Theme) 266 } 267 /> 268 </div> 269 )} 270 </div> 271 <div className="mb-6"> 272 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 273 Description 274 </label> 275 <textarea 276 value={newItemDesc} 277 onChange={(e) => setNewItemDesc(e.target.value)} 278 className="w-full px-3 py-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[80px] resize-none" 279 placeholder="What's this collection for?" 280 /> 281 </div> 282 <div className="flex justify-end gap-2"> 283 <Button 284 type="button" 285 variant="ghost" 286 onClick={() => setShowCreateModal(false)} 287 > 288 Cancel 289 </Button> 290 <Button type="submit" loading={creating}> 291 Create Collection 292 </Button> 293 </div> 294 </form> 295 </div> 296 </div> 297 )} 298 </div> 299 ); 300}