(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 291 lines 12 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"; 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}