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