Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}