Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at v0.1.15 9.0 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} from "lucide-react"; 45import { createCollection, updateCollection } from "../api/client"; 46 47const EMOJI_OPTIONS = [ 48 "📁", 49 "📚", 50 "💡", 51 "⭐", 52 "🔖", 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 80const ICON_OPTIONS = [ 81 { icon: Folder, name: "folder" }, 82 { icon: Star, name: "star" }, 83 { icon: Heart, name: "heart" }, 84 { icon: Bookmark, name: "bookmark" }, 85 { icon: Lightbulb, name: "lightbulb" }, 86 { icon: Zap, name: "zap" }, 87 { icon: Coffee, name: "coffee" }, 88 { icon: Music, name: "music" }, 89 { icon: Camera, name: "camera" }, 90 { icon: Code, name: "code" }, 91 { icon: Globe, name: "globe" }, 92 { icon: Flag, name: "flag" }, 93 { icon: Tag, name: "tag" }, 94 { icon: Box, name: "box" }, 95 { icon: Archive, name: "archive" }, 96 { icon: FileText, name: "file" }, 97 { icon: Image, name: "image" }, 98 { icon: Video, name: "video" }, 99 { icon: Mail, name: "mail" }, 100 { icon: MapPin, name: "pin" }, 101 { icon: Calendar, name: "calendar" }, 102 { icon: Clock, name: "clock" }, 103 { icon: Search, name: "search" }, 104 { icon: Settings, name: "settings" }, 105 { icon: User, name: "user" }, 106 { icon: Users, name: "users" }, 107 { icon: Home, name: "home" }, 108 { icon: Briefcase, name: "briefcase" }, 109 { icon: Gift, name: "gift" }, 110 { icon: Award, name: "award" }, 111 { icon: Target, name: "target" }, 112 { icon: TrendingUp, name: "trending" }, 113 { icon: Activity, name: "activity" }, 114 { icon: Cpu, name: "cpu" }, 115 { icon: Database, name: "database" }, 116 { icon: Cloud, name: "cloud" }, 117 { icon: Sun, name: "sun" }, 118 { icon: Moon, name: "moon" }, 119 { icon: Flame, name: "flame" }, 120 { icon: Leaf, name: "leaf" }, 121]; 122 123export default function CollectionModal({ 124 isOpen, 125 onClose, 126 onSuccess, 127 collectionToEdit, 128}) { 129 const [name, setName] = useState(""); 130 const [description, setDescription] = useState(""); 131 const [icon, setIcon] = useState(""); 132 const [customEmoji, setCustomEmoji] = useState(""); 133 const [activeTab, setActiveTab] = useState("emoji"); 134 const [loading, setLoading] = useState(false); 135 const [error, setError] = useState(null); 136 137 useEffect(() => { 138 if (collectionToEdit) { 139 setName(collectionToEdit.name); 140 setDescription(collectionToEdit.description || ""); 141 const savedIcon = collectionToEdit.icon || ""; 142 setIcon(savedIcon); 143 setCustomEmoji(savedIcon); 144 145 if (savedIcon.startsWith("icon:")) { 146 setActiveTab("icons"); 147 } 148 } else { 149 setName(""); 150 setDescription(""); 151 setIcon(""); 152 setCustomEmoji(""); 153 } 154 setError(null); 155 }, [collectionToEdit, isOpen]); 156 157 if (!isOpen) return null; 158 159 const handleEmojiSelect = (emoji) => { 160 if (icon === emoji) { 161 setIcon(""); 162 setCustomEmoji(""); 163 } else { 164 setIcon(emoji); 165 setCustomEmoji(emoji); 166 } 167 }; 168 169 const handleIconSelect = (iconName) => { 170 const value = `icon:${iconName}`; 171 if (icon === value) { 172 setIcon(""); 173 setCustomEmoji(""); 174 } else { 175 setIcon(value); 176 setCustomEmoji(value); 177 } 178 }; 179 180 const handleCustomEmojiChange = (e) => { 181 const value = e.target.value; 182 setCustomEmoji(value); 183 const emojiMatch = value.match( 184 /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/gu, 185 ); 186 if (emojiMatch && emojiMatch.length > 0) { 187 setIcon(emojiMatch[emojiMatch.length - 1]); 188 } else if (value === "") { 189 setIcon(""); 190 } 191 }; 192 193 const handleSubmit = async (e) => { 194 e.preventDefault(); 195 setLoading(true); 196 setError(null); 197 198 try { 199 if (collectionToEdit) { 200 await updateCollection(collectionToEdit.uri, name, description, icon); 201 } else { 202 await createCollection(name, description, icon); 203 } 204 onSuccess(); 205 onClose(); 206 } catch (err) { 207 console.error(err); 208 setError(err.message || "Failed to save collection"); 209 } finally { 210 setLoading(false); 211 } 212 }; 213 214 return ( 215 <div className="modal-overlay" onClick={onClose}> 216 <div 217 className="modal-container" 218 style={{ maxWidth: "420px" }} 219 onClick={(e) => e.stopPropagation()} 220 > 221 <div className="modal-header"> 222 <h2 className="modal-title"> 223 {collectionToEdit ? "Edit Collection" : "New Collection"} 224 </h2> 225 <button onClick={onClose} className="modal-close-btn"> 226 <X size={20} /> 227 </button> 228 </div> 229 230 <form onSubmit={handleSubmit} className="modal-form"> 231 {error && ( 232 <div 233 className="card text-error" 234 style={{ 235 padding: "12px", 236 background: "rgba(239, 68, 68, 0.1)", 237 borderColor: "rgba(239, 68, 68, 0.2)", 238 fontSize: "0.9rem", 239 }} 240 > 241 {error} 242 </div> 243 )} 244 245 <div className="form-group"> 246 <label className="form-label">Icon</label> 247 <div className="icon-picker-tabs"> 248 <button 249 type="button" 250 className={`icon-picker-tab ${activeTab === "emoji" ? "active" : ""}`} 251 onClick={() => setActiveTab("emoji")} 252 > 253 Emoji 254 </button> 255 <button 256 type="button" 257 className={`icon-picker-tab ${activeTab === "icons" ? "active" : ""}`} 258 onClick={() => setActiveTab("icons")} 259 > 260 Icons 261 </button> 262 </div> 263 264 {activeTab === "emoji" && ( 265 <div className="emoji-picker-wrapper"> 266 <div className="emoji-custom-input"> 267 <input 268 type="text" 269 value={customEmoji.startsWith("icon:") ? "" : customEmoji} 270 onChange={handleCustomEmojiChange} 271 placeholder="Type any emoji..." 272 className="form-input" 273 /> 274 </div> 275 <div className="emoji-picker"> 276 {EMOJI_OPTIONS.map((emoji) => ( 277 <button 278 key={emoji} 279 type="button" 280 className={`emoji-option ${icon === emoji ? "selected" : ""}`} 281 onClick={() => handleEmojiSelect(emoji)} 282 > 283 {emoji} 284 </button> 285 ))} 286 </div> 287 </div> 288 )} 289 290 {activeTab === "icons" && ( 291 <div className="icon-picker"> 292 {ICON_OPTIONS.map(({ icon: IconComponent, name: iconName }) => ( 293 <button 294 key={iconName} 295 type="button" 296 className={`icon-option ${icon === `icon:${iconName}` ? "selected" : ""}`} 297 onClick={() => handleIconSelect(iconName)} 298 > 299 <IconComponent size={20} /> 300 </button> 301 ))} 302 </div> 303 )} 304 </div> 305 306 <div className="form-group"> 307 <label className="form-label">Name</label> 308 <input 309 type="text" 310 value={name} 311 onChange={(e) => setName(e.target.value)} 312 required 313 className="form-input" 314 placeholder="My Favorites" 315 /> 316 </div> 317 318 <div className="form-group"> 319 <label className="form-label">Description</label> 320 <textarea 321 value={description} 322 onChange={(e) => setDescription(e.target.value)} 323 rows={2} 324 className="form-textarea" 325 placeholder="A collection of..." 326 /> 327 </div> 328 329 <div className="modal-actions"> 330 <button type="button" onClick={onClose} className="btn btn-ghost"> 331 Cancel 332 </button> 333 <button 334 type="submit" 335 disabled={loading} 336 className="btn btn-primary" 337 style={loading ? { opacity: 0.7, cursor: "wait" } : {}} 338 > 339 {loading 340 ? "Saving..." 341 : collectionToEdit 342 ? "Save Changes" 343 : "Create Collection"} 344 </button> 345 </div> 346 </form> 347 </div> 348 </div> 349 ); 350}