(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 frontend-rewrite 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}