Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 396 lines 10 kB view raw
1import { useState, useEffect } from "react"; 2import { 3 X, 4 Folder, 5 Star, 6 Heart, 7 Bookmark, 8 Lightbulb, 9 Zap, 10 Coffee, 11 Music, 12 Camera, 13 Code, 14 Globe, 15 Flag, 16 Tag, 17 Box, 18 Archive, 19 FileText, 20 Image, 21 Video, 22 Mail, 23 MapPin, 24 Calendar, 25 Clock, 26 Search, 27 Settings, 28 User, 29 Users, 30 Home, 31 Briefcase, 32 Gift, 33 Award, 34 Target, 35 TrendingUp, 36 Activity, 37 Cpu, 38 Database, 39 Cloud, 40 Sun, 41 Moon, 42 Flame, 43 Leaf, 44 Trash2, 45} from "lucide-react"; 46import { 47 createCollection, 48 updateCollection, 49 deleteCollection, 50} from "../api/client"; 51 52const EMOJI_OPTIONS = [ 53 "📁", 54 "📚", 55 "💡", 56 "⭐", 57 "🔖", 58 "💻", 59 "🎨", 60 "📝", 61 "🔬", 62 "🎯", 63 "🚀", 64 "💎", 65 "🌟", 66 "📌", 67 "💼", 68 "🎮", 69 "🎵", 70 "🎬", 71 "❤️", 72 "🔥", 73 "🌈", 74 "🌸", 75 "🌿", 76 "🧠", 77 "🏆", 78 "📊", 79 "🎓", 80 "✨", 81 "🔧", 82 "⚡", 83]; 84 85const ICON_OPTIONS = [ 86 { icon: Folder, name: "folder" }, 87 { icon: Star, name: "star" }, 88 { icon: Heart, name: "heart" }, 89 { icon: Bookmark, name: "bookmark" }, 90 { icon: Lightbulb, name: "lightbulb" }, 91 { icon: Zap, name: "zap" }, 92 { icon: Coffee, name: "coffee" }, 93 { icon: Music, name: "music" }, 94 { icon: Camera, name: "camera" }, 95 { icon: Code, name: "code" }, 96 { icon: Globe, name: "globe" }, 97 { icon: Flag, name: "flag" }, 98 { icon: Tag, name: "tag" }, 99 { icon: Box, name: "box" }, 100 { icon: Archive, name: "archive" }, 101 { icon: FileText, name: "file" }, 102 { icon: Image, name: "image" }, 103 { icon: Video, name: "video" }, 104 { icon: Mail, name: "mail" }, 105 { icon: MapPin, name: "pin" }, 106 { icon: Calendar, name: "calendar" }, 107 { icon: Clock, name: "clock" }, 108 { icon: Search, name: "search" }, 109 { icon: Settings, name: "settings" }, 110 { icon: User, name: "user" }, 111 { icon: Users, name: "users" }, 112 { icon: Home, name: "home" }, 113 { icon: Briefcase, name: "briefcase" }, 114 { icon: Gift, name: "gift" }, 115 { icon: Award, name: "award" }, 116 { icon: Target, name: "target" }, 117 { icon: TrendingUp, name: "trending" }, 118 { icon: Activity, name: "activity" }, 119 { icon: Cpu, name: "cpu" }, 120 { icon: Database, name: "database" }, 121 { icon: Cloud, name: "cloud" }, 122 { icon: Sun, name: "sun" }, 123 { icon: Moon, name: "moon" }, 124 { icon: Flame, name: "flame" }, 125 { icon: Leaf, name: "leaf" }, 126]; 127 128export default function CollectionModal({ 129 isOpen, 130 onClose, 131 onSuccess, 132 collectionToEdit, 133 onDelete, 134}) { 135 const [name, setName] = useState(""); 136 const [description, setDescription] = useState(""); 137 const [icon, setIcon] = useState(""); 138 const [customEmoji, setCustomEmoji] = useState(""); 139 const [activeTab, setActiveTab] = useState("emoji"); 140 const [loading, setLoading] = useState(false); 141 const [deleting, setDeleting] = useState(false); 142 const [error, setError] = useState(null); 143 144 useEffect(() => { 145 if (collectionToEdit) { 146 setName(collectionToEdit.name); 147 setDescription(collectionToEdit.description || ""); 148 const savedIcon = collectionToEdit.icon || ""; 149 setIcon(savedIcon); 150 setCustomEmoji(savedIcon); 151 152 if (savedIcon.startsWith("icon:")) { 153 setActiveTab("icons"); 154 } 155 } else { 156 setName(""); 157 setDescription(""); 158 setIcon(""); 159 setCustomEmoji(""); 160 } 161 setError(null); 162 }, [collectionToEdit, isOpen]); 163 164 if (!isOpen) return null; 165 166 const handleEmojiSelect = (emoji) => { 167 if (icon === emoji) { 168 setIcon(""); 169 setCustomEmoji(""); 170 } else { 171 setIcon(emoji); 172 setCustomEmoji(emoji); 173 } 174 }; 175 176 const handleIconSelect = (iconName) => { 177 const value = `icon:${iconName}`; 178 if (icon === value) { 179 setIcon(""); 180 setCustomEmoji(""); 181 } else { 182 setIcon(value); 183 setCustomEmoji(value); 184 } 185 }; 186 187 const handleCustomEmojiChange = (e) => { 188 const value = e.target.value; 189 setCustomEmoji(value); 190 const emojiMatch = value.match( 191 /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/gu, 192 ); 193 if (emojiMatch && emojiMatch.length > 0) { 194 setIcon(emojiMatch[emojiMatch.length - 1]); 195 } else if (value === "") { 196 setIcon(""); 197 } 198 }; 199 200 const handleSubmit = async (e) => { 201 e.preventDefault(); 202 setLoading(true); 203 setError(null); 204 205 try { 206 if (collectionToEdit) { 207 await updateCollection(collectionToEdit.uri, name, description, icon); 208 } else { 209 await createCollection(name, description, icon); 210 } 211 onSuccess(); 212 onClose(); 213 } catch (err) { 214 console.error(err); 215 setError(err.message || "Failed to save collection"); 216 } finally { 217 setLoading(false); 218 } 219 }; 220 221 const handleDelete = async () => { 222 if ( 223 !confirm( 224 "Delete this collection and all its items? This cannot be undone.", 225 ) 226 ) { 227 return; 228 } 229 setDeleting(true); 230 setError(null); 231 232 try { 233 await deleteCollection(collectionToEdit.uri); 234 if (onDelete) { 235 onDelete(); 236 } else { 237 onSuccess(); 238 } 239 onClose(); 240 } catch (err) { 241 console.error(err); 242 setError(err.message || "Failed to delete collection"); 243 } finally { 244 setDeleting(false); 245 } 246 }; 247 248 return ( 249 <div className="modal-overlay" onClick={onClose}> 250 <div 251 className="modal-container" 252 style={{ maxWidth: "420px" }} 253 onClick={(e) => e.stopPropagation()} 254 > 255 <div className="modal-header"> 256 <h2 className="modal-title"> 257 {collectionToEdit ? "Edit Collection" : "New Collection"} 258 </h2> 259 <button onClick={onClose} className="modal-close-btn"> 260 <X size={20} /> 261 </button> 262 </div> 263 264 <form onSubmit={handleSubmit} className="modal-form"> 265 {error && ( 266 <div 267 className="card text-error" 268 style={{ 269 padding: "12px", 270 background: "rgba(239, 68, 68, 0.1)", 271 borderColor: "rgba(239, 68, 68, 0.2)", 272 fontSize: "0.9rem", 273 }} 274 > 275 {error} 276 </div> 277 )} 278 279 <div className="form-group"> 280 <label className="form-label">Icon</label> 281 <div className="icon-picker-tabs"> 282 <button 283 type="button" 284 className={`icon-picker-tab ${activeTab === "emoji" ? "active" : ""}`} 285 onClick={() => setActiveTab("emoji")} 286 > 287 Emoji 288 </button> 289 <button 290 type="button" 291 className={`icon-picker-tab ${activeTab === "icons" ? "active" : ""}`} 292 onClick={() => setActiveTab("icons")} 293 > 294 Icons 295 </button> 296 </div> 297 298 {activeTab === "emoji" && ( 299 <div className="emoji-picker-wrapper"> 300 <div className="emoji-custom-input"> 301 <input 302 type="text" 303 value={customEmoji.startsWith("icon:") ? "" : customEmoji} 304 onChange={handleCustomEmojiChange} 305 placeholder="Type any emoji..." 306 className="form-input" 307 /> 308 </div> 309 <div className="emoji-picker"> 310 {EMOJI_OPTIONS.map((emoji) => ( 311 <button 312 key={emoji} 313 type="button" 314 className={`emoji-option ${icon === emoji ? "selected" : ""}`} 315 onClick={() => handleEmojiSelect(emoji)} 316 > 317 {emoji} 318 </button> 319 ))} 320 </div> 321 </div> 322 )} 323 324 {activeTab === "icons" && ( 325 <div className="icon-picker"> 326 {ICON_OPTIONS.map(({ icon: IconComponent, name: iconName }) => ( 327 <button 328 key={iconName} 329 type="button" 330 className={`icon-option ${icon === `icon:${iconName}` ? "selected" : ""}`} 331 onClick={() => handleIconSelect(iconName)} 332 > 333 <IconComponent size={20} /> 334 </button> 335 ))} 336 </div> 337 )} 338 </div> 339 340 <div className="form-group"> 341 <label className="form-label">Name</label> 342 <input 343 type="text" 344 value={name} 345 onChange={(e) => setName(e.target.value)} 346 required 347 className="form-input" 348 placeholder="My Favorites" 349 /> 350 </div> 351 352 <div className="form-group"> 353 <label className="form-label">Description</label> 354 <textarea 355 value={description} 356 onChange={(e) => setDescription(e.target.value)} 357 rows={2} 358 className="form-textarea" 359 placeholder="A collection of..." 360 /> 361 </div> 362 363 <div className="modal-actions"> 364 {collectionToEdit && ( 365 <button 366 type="button" 367 onClick={handleDelete} 368 disabled={deleting} 369 className="btn btn-danger" 370 > 371 <Trash2 size={16} /> 372 {deleting ? "Deleting..." : "Delete"} 373 </button> 374 )} 375 <div style={{ flex: 1 }} /> 376 <button type="button" onClick={onClose} className="btn btn-ghost"> 377 Cancel 378 </button> 379 <button 380 type="submit" 381 disabled={loading} 382 className="btn btn-primary" 383 style={loading ? { opacity: 0.7, cursor: "wait" } : {}} 384 > 385 {loading 386 ? "Saving..." 387 : collectionToEdit 388 ? "Save Changes" 389 : "Create Collection"} 390 </button> 391 </div> 392 </form> 393 </div> 394 </div> 395 ); 396}