Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect } from "react";
2import { X, Loader2 } from "lucide-react";
3import CollectionIcon from "../common/CollectionIcon";
4import { ICON_MAP } from "../common/iconMap";
5import EmojiPicker, { Theme } from "emoji-picker-react";
6import { updateCollection, type Collection } from "../../api/client";
7import { useStore } from "@nanostores/react";
8import { $theme } from "../../store/theme";
9
10interface EditCollectionModalProps {
11 isOpen: boolean;
12 onClose: () => void;
13 collection: Collection;
14 onUpdate: (updatedCollection: Collection) => void;
15}
16
17export default function EditCollectionModal({
18 isOpen,
19 onClose,
20 collection,
21 onUpdate,
22}: EditCollectionModalProps) {
23 const [name, setName] = useState(collection.name);
24 const [description, setDescription] = useState(collection.description || "");
25 const initialIsIcon = collection.icon?.startsWith("icon:") ?? false;
26 const initialIconValue = collection.icon?.replace("icon:", "") || "";
27
28 const [activeTab, setActiveTab] = useState<"icon" | "emoji">(
29 initialIsIcon || !collection.icon ? "icon" : "emoji",
30 );
31 const [icon, setIcon] = useState(initialIconValue);
32 const [loading, setLoading] = useState(false);
33 const [error, setError] = useState<string | null>(null);
34 const theme = useStore($theme);
35
36 useEffect(() => {
37 if (isOpen) {
38 setName(collection.name);
39 setDescription(collection.description || "");
40
41 const isIcon = collection.icon?.startsWith("icon:") ?? false;
42 setActiveTab(isIcon || !collection.icon ? "icon" : "emoji");
43 setIcon(collection.icon?.replace("icon:", "") || "");
44
45 setError(null);
46 document.body.style.overflow = "hidden";
47 }
48 return () => {
49 document.body.style.overflow = "unset";
50 };
51 }, [isOpen, collection]);
52
53 const handleSubmit = async (e: React.FormEvent) => {
54 e.preventDefault();
55 if (!name.trim()) return;
56
57 try {
58 setLoading(true);
59 setError(null);
60 const iconValue = icon
61 ? ICON_MAP[icon]
62 ? `icon:${icon}`
63 : icon
64 : undefined;
65 const updated = await updateCollection(
66 collection.uri,
67 name.trim(),
68 description.trim() || undefined,
69 iconValue,
70 );
71
72 if (updated) {
73 onUpdate(updated);
74 onClose();
75 } else {
76 setError("Failed to update collection");
77 }
78 } catch (err) {
79 console.error(err);
80 setError("An error occurred while updating");
81 } finally {
82 setLoading(false);
83 }
84 };
85
86 if (!isOpen) return null;
87
88 return (
89 <div
90 className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
91 onClick={onClose}
92 >
93 <div
94 className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden"
95 onClick={(e) => e.stopPropagation()}
96 >
97 <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800">
98 <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white">
99 Edit Collection
100 </h2>
101 <button
102 onClick={onClose}
103 className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors"
104 >
105 <X size={20} />
106 </button>
107 </div>
108
109 <div className="p-6">
110 <form onSubmit={handleSubmit} className="space-y-4">
111 <div>
112 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
113 Collection name
114 </label>
115 <input
116 type="text"
117 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500"
118 value={name}
119 onChange={(e) => setName(e.target.value)}
120 placeholder="My Collection"
121 autoFocus
122 />
123 </div>
124
125 <div>
126 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
127 Description (optional)
128 </label>
129 <textarea
130 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500 resize-none"
131 value={description}
132 onChange={(e) => setDescription(e.target.value)}
133 placeholder="What's this collection about?"
134 rows={3}
135 />
136 </div>
137
138 <div>
139 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
140 Icon
141 </label>
142
143 <div className="flex gap-2 mb-3 bg-surface-100 dark:bg-surface-800 p-1 rounded-xl">
144 <button
145 type="button"
146 onClick={() => setActiveTab("icon")}
147 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
148 activeTab === "icon"
149 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
150 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
151 }`}
152 >
153 Icons
154 </button>
155 <button
156 type="button"
157 onClick={() => setActiveTab("emoji")}
158 className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-colors ${
159 activeTab === "emoji"
160 ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm"
161 : "text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200"
162 }`}
163 >
164 Emojis
165 </button>
166 </div>
167
168 {activeTab === "icon" ? (
169 <div className="grid grid-cols-8 gap-1.5 max-h-60 overflow-y-auto p-2 bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 custom-scrollbar">
170 {Object.keys(ICON_MAP).map((iconName) => {
171 const isSelected = icon === iconName;
172 return (
173 <button
174 key={iconName}
175 type="button"
176 onClick={() => setIcon(isSelected ? "" : iconName)}
177 className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${
178 isSelected
179 ? "bg-primary-600 text-white"
180 : "hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-600 dark:text-surface-400"
181 }`}
182 title={iconName}
183 >
184 <CollectionIcon icon={`icon:${iconName}`} size={16} />
185 </button>
186 );
187 })}
188 </div>
189 ) : (
190 <div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
191 <EmojiPicker
192 className="custom-emoji-picker"
193 onEmojiClick={(emojiData) => setIcon(emojiData.emoji)}
194 autoFocusSearch={false}
195 width="100%"
196 height={300}
197 previewConfig={{ showPreview: false }}
198 skinTonesDisabled
199 lazyLoadEmojis
200 theme={
201 theme === "dark" ||
202 (theme === "system" &&
203 window.matchMedia("(prefers-color-scheme: dark)")
204 .matches)
205 ? (Theme.DARK as Theme)
206 : (Theme.LIGHT as Theme)
207 }
208 />
209 </div>
210 )}
211
212 {icon && (
213 <p className="mt-2 text-sm text-surface-600 dark:text-surface-300 flex items-center gap-2">
214 Selected:
215 <span className="inline-flex items-center justify-center w-8 h-8 bg-surface-100 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
216 <CollectionIcon
217 icon={ICON_MAP[icon] ? `icon:${icon}` : icon}
218 size={18}
219 />
220 </span>
221 </p>
222 )}
223 </div>
224
225 {error && (
226 <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg">
227 {error}
228 </div>
229 )}
230
231 <div className="flex gap-3 pt-2">
232 <button
233 type="button"
234 className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors"
235 onClick={onClose}
236 >
237 Cancel
238 </button>
239 <button
240 type="submit"
241 className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
242 disabled={!name.trim() || loading}
243 >
244 {loading && <Loader2 size={16} className="animate-spin" />}
245 {loading ? "Saving..." : "Save Changes"}
246 </button>
247 </div>
248 </form>
249 </div>
250 </div>
251 </div>
252 );
253}