(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, useRef } from "react";
2import { updateProfile, uploadAvatar, getAvatarUrl } from "../../api/client";
3import type { UserProfile } from "../../types";
4import { Loader2, X, Plus, User as UserIcon } from "lucide-react";
5import { useTranslation } from "react-i18next";
6
7interface EditProfileModalProps {
8 profile: UserProfile;
9 onClose: () => void;
10 onUpdate: (updatedProfile: UserProfile) => void;
11}
12
13export default function EditProfileModal({
14 profile,
15 onClose,
16 onUpdate,
17}: EditProfileModalProps) {
18 const { t } = useTranslation();
19 const [displayName, setDisplayName] = useState(profile.displayName || "");
20 const [description, setDescription] = useState(profile.description || "");
21 const [website, setWebsite] = useState(profile.website || "");
22 const [links, setLinks] = useState<string[]>(profile.links || []);
23 const [newLink, setNewLink] = useState("");
24
25 const [avatarBlob, setAvatarBlob] = useState<Blob | string | null>(null);
26 const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
27 const [uploading, setUploading] = useState(false);
28
29 const [saving, setSaving] = useState(false);
30 const [error, setError] = useState<string | null>(null);
31 const fileInputRef = useRef<HTMLInputElement>(null);
32
33 const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
34 const file = e.target.files?.[0];
35 if (!file) return;
36
37 if (!["image/jpeg", "image/png"].includes(file.type)) {
38 setError(t("editProfile.avatarTypeError"));
39 return;
40 }
41
42 if (file.size > 1024 * 1024 * 2) {
43 setError(t("editProfile.avatarSizeError"));
44 return;
45 }
46
47 setAvatarPreview(URL.createObjectURL(file));
48 setAvatarBlob(file);
49
50 setUploading(true);
51 try {
52 const result = await uploadAvatar(file);
53 setAvatarBlob(result.blob);
54 setAvatarBlob(result.blob);
55 } catch (err) {
56 setError(
57 t("editProfile.avatarUploadError", {
58 message: err instanceof Error ? err.message : "Unknown error",
59 }),
60 );
61 setAvatarPreview(null);
62 } finally {
63 setUploading(false);
64 }
65 };
66
67 const handleAddLink = () => {
68 if (!newLink) return;
69 if (!links.includes(newLink)) {
70 setLinks([...links, newLink]);
71 setNewLink("");
72 }
73 };
74
75 const handleRemoveLink = (index: number) => {
76 setLinks(links.filter((_, i) => i !== index));
77 };
78
79 const handleSubmit = async (e: React.FormEvent) => {
80 e.preventDefault();
81 setSaving(true);
82 setError(null);
83
84 try {
85 await updateProfile({
86 displayName,
87 description,
88 website,
89 links,
90 avatar: avatarBlob,
91 });
92 onUpdate({
93 ...profile,
94 displayName,
95 description,
96 website,
97 links,
98 avatar: avatarPreview || profile.avatar,
99 });
100 onClose();
101 onClose();
102 } catch (err) {
103 setError(err instanceof Error ? err.message : "Unknown error");
104 } finally {
105 setSaving(false);
106 }
107 };
108
109 const currentAvatar =
110 avatarPreview || getAvatarUrl(profile.did, profile.avatar);
111
112 return (
113 <div
114 className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
115 onClick={onClose}
116 >
117 <div
118 className="bg-white dark:bg-surface-900 rounded-xl w-full max-w-md overflow-hidden shadow-2xl ring-1 ring-black/5 dark:ring-white/10"
119 onClick={(e) => e.stopPropagation()}
120 >
121 <div className="flex items-center justify-between p-4 border-b border-surface-100 dark:border-surface-800">
122 <h2 className="text-lg font-bold text-surface-900 dark:text-white">
123 {t("editProfile.title")}
124 </h2>
125 <button
126 onClick={onClose}
127 className="p-1.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors text-surface-500 dark:text-surface-400"
128 >
129 <X size={18} />
130 </button>
131 </div>
132
133 <form
134 onSubmit={handleSubmit}
135 className="p-5 overflow-y-auto max-h-[80vh]"
136 >
137 {error && (
138 <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm border border-red-100 dark:border-red-800">
139 {error}
140 </div>
141 )}
142
143 <div className="mb-5">
144 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
145 {t("editProfile.avatarLabel")}
146 </label>
147 <div className="flex items-center gap-3">
148 <div
149 className="relative w-16 h-16 rounded-full bg-surface-100 dark:bg-surface-800 overflow-hidden cursor-pointer group border border-surface-200 dark:border-surface-700"
150 onClick={() => fileInputRef.current?.click()}
151 >
152 {currentAvatar ? (
153 <img
154 src={currentAvatar}
155 alt=""
156 className="w-full h-full object-cover"
157 />
158 ) : (
159 <div className="w-full h-full flex items-center justify-center text-surface-400 dark:text-surface-500">
160 <UserIcon size={24} />
161 </div>
162 )}
163 <div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
164 <span className="text-white text-xs font-medium">Edit</span>
165 </div>
166 </div>
167 <input
168 ref={fileInputRef}
169 type="file"
170 accept="image/jpeg,image/png"
171 onChange={handleAvatarChange}
172 className="hidden"
173 />
174 <button
175 type="button"
176 onClick={() => fileInputRef.current?.click()}
177 className="px-3 py-1.5 rounded-lg bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-900 dark:text-white font-medium text-sm transition-colors"
178 disabled={uploading}
179 >
180 {uploading
181 ? t("editProfile.uploading")
182 : t("editProfile.uploadButton")}
183 </button>
184 </div>
185 </div>
186
187 <div className="mb-4">
188 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
189 {t("editProfile.displayNameLabel")}
190 </label>
191 <input
192 type="text"
193 value={displayName}
194 onChange={(e) => setDisplayName(e.target.value)}
195 className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400"
196 maxLength={64}
197 />
198 </div>
199
200 <div className="mb-4">
201 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
202 {t("editProfile.bioLabel")}
203 </label>
204 <textarea
205 value={description}
206 onChange={(e) => setDescription(e.target.value)}
207 className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[80px] resize-none"
208 maxLength={300}
209 />
210 </div>
211
212 <div className="mb-4">
213 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
214 {t("editProfile.websiteLabel")}
215 </label>
216 <input
217 type="url"
218 value={website}
219 onChange={(e) => setWebsite(e.target.value)}
220 placeholder="https://example.com"
221 className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm"
222 />
223 </div>
224
225 <div className="mb-5">
226 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
227 {t("editProfile.linksLabel")}
228 </label>
229 <div className="space-y-2">
230 {links.map((link, i) => (
231 <div key={i} className="flex items-center gap-2">
232 <input
233 type="text"
234 value={link}
235 readOnly
236 className="flex-1 px-3 py-2 rounded-lg bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-sm text-surface-600 dark:text-surface-300"
237 />
238 <button
239 type="button"
240 onClick={() => handleRemoveLink(i)}
241 className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
242 >
243 <X size={14} />
244 </button>
245 </div>
246 ))}
247 <div className="flex items-center gap-2">
248 <input
249 type="url"
250 value={newLink}
251 onChange={(e) => setNewLink(e.target.value)}
252 placeholder={t("editProfile.addLinkPlaceholder")}
253 className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm"
254 onKeyDown={(e) =>
255 e.key === "Enter" && (e.preventDefault(), handleAddLink())
256 }
257 />
258 <button
259 type="button"
260 onClick={handleAddLink}
261 className="p-2 bg-surface-900 dark:bg-surface-700 text-white rounded-lg hover:bg-surface-800 dark:hover:bg-surface-600"
262 >
263 <Plus size={18} />
264 </button>
265 </div>
266 </div>
267 </div>
268
269 <div className="flex items-center justify-end gap-2 pt-4 border-t border-surface-100 dark:border-surface-800">
270 <button
271 type="button"
272 onClick={onClose}
273 className="px-4 py-2 rounded-lg text-surface-600 dark:text-surface-300 font-medium hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
274 disabled={saving}
275 >
276 {t("editProfile.cancel")}
277 </button>
278 <button
279 type="submit"
280 className="px-4 py-2 rounded-lg bg-primary-600 text-white font-medium hover:bg-primary-700 transition-colors flex items-center gap-2"
281 disabled={saving}
282 >
283 {saving && <Loader2 size={14} className="animate-spin" />}
284 {saving ? t("editProfile.saving") : t("editProfile.save")}
285 </button>
286 </div>
287 </form>
288 </div>
289 </div>
290 );
291}