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