Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 286 lines 11 kB view raw
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}