11import * as React from "react" ;
2- import { Upload , X } from "lucide-react" ;
2+ import { Pencil , Plus } from "lucide-react" ;
3+ import { AnimatePresence , motion , useReducedMotion } from "motion/react" ;
34
45import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar" ;
56import { useAvatarUpload } from "@/features/profile/useAvatarUpload" ;
67import { cn } from "@/shared/lib/cn" ;
8+ import { Button } from "@/shared/ui/button" ;
9+ import { Input } from "@/shared/ui/input" ;
10+ import { Popover , PopoverContent , PopoverTrigger } from "@/shared/ui/popover" ;
711import { Spinner } from "@/shared/ui/spinner" ;
812
913function isAvatarFileDrag ( event : React . DragEvent < HTMLElement > ) {
1014 return Array . from ( event . dataTransfer . types ) . includes ( "Files" ) ;
1115}
1216
17+ const AVATAR_APPLY_MOTION_TRANSITION = {
18+ duration : 0.14 ,
19+ ease : [ 0.23 , 1 , 0.32 , 1 ] ,
20+ } as const ;
21+
1322export function AgentCreationPreview ( {
1423 avatarUrl,
1524 disabled = false ,
@@ -25,8 +34,14 @@ export function AgentCreationPreview({
2534 onUploadPendingChange ?: ( isPending : boolean ) => void ;
2635 onSelectAvatar : ( avatarUrl : string ) => void ;
2736} ) {
37+ const avatarEditClipId = React . useId ( ) . replace ( / : / g, "" ) ;
2838 const [ isDragOverAvatar , setIsDragOverAvatar ] = React . useState ( false ) ;
39+ const [ isAvatarMenuOpen , setIsAvatarMenuOpen ] = React . useState ( false ) ;
40+ const [ avatarUrlDraft , setAvatarUrlDraft ] = React . useState ( "" ) ;
41+ const [ isAvatarUrlInputFocused , setIsAvatarUrlInputFocused ] =
42+ React . useState ( false ) ;
2943 const avatarDragDepthRef = React . useRef ( 0 ) ;
44+ const shouldReduceMotion = useReducedMotion ( ) ;
3045 const {
3146 inputRef : avatarUploadInputRef ,
3247 isUploading,
@@ -46,6 +61,36 @@ export function AgentCreationPreview({
4661 } ;
4762 } , [ isUploading , onUploadPendingChange ] ) ;
4863
64+ React . useEffect ( ( ) => {
65+ if ( isAvatarMenuOpen ) {
66+ setAvatarUrlDraft ( "" ) ;
67+ setIsAvatarUrlInputFocused ( false ) ;
68+ }
69+ } , [ isAvatarMenuOpen ] ) ;
70+
71+ function applyAvatarUrl ( ) {
72+ const nextUrl = avatarUrlDraft . trim ( ) ;
73+ if ( nextUrl . length === 0 ) {
74+ return ;
75+ }
76+ clearUploadError ( ) ;
77+ onSelectAvatar ( nextUrl ) ;
78+ setIsAvatarMenuOpen ( false ) ;
79+ }
80+
81+ const avatarClipStyle = React . useMemo < React . CSSProperties > (
82+ ( ) => ( {
83+ clipPath : `url(#${ avatarEditClipId } )` ,
84+ transform : "translateZ(0)" ,
85+ } ) ,
86+ [ avatarEditClipId ] ,
87+ ) ;
88+ const hasAvatarUrlDraft = avatarUrlDraft . trim ( ) . length > 0 ;
89+ const hasAvatar = ( avatarUrl ?. trim ( ) . length ?? 0 ) > 0 ;
90+ const applyButtonTransition = shouldReduceMotion
91+ ? { duration : 0 }
92+ : AVATAR_APPLY_MOTION_TRANSITION ;
93+
4994 const handleAvatarDragEnter = React . useCallback (
5095 ( event : React . DragEvent < HTMLFieldSetElement > ) => {
5196 if ( disabled || ! isAvatarFileDrag ( event ) ) {
@@ -109,12 +154,93 @@ export function AgentCreationPreview({
109154 [ clearUploadError , disabled , isUploading , uploadAvatarFile ] ,
110155 ) ;
111156
157+ const avatarMenuContent = (
158+ < PopoverContent align = "center" className = "w-72 space-y-1 p-1" >
159+ < button
160+ className = "flex min-h-9 w-full items-center rounded-lg px-2.5 text-left text-sm outline-hidden transition-colors duration-150 ease-out hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
161+ disabled = { disabled || isUploading }
162+ onClick = { ( ) => {
163+ clearUploadError ( ) ;
164+ openUploadPicker ( ) ;
165+ setIsAvatarMenuOpen ( false ) ;
166+ } }
167+ type = "button"
168+ >
169+ Upload an image
170+ </ button >
171+ < form
172+ className = "flex min-h-9 items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors duration-150 ease-out focus-within:bg-muted/50"
173+ onSubmit = { ( event ) => {
174+ event . preventDefault ( ) ;
175+ applyAvatarUrl ( ) ;
176+ } }
177+ >
178+ < label className = "sr-only" htmlFor = "agent-avatar-url" >
179+ Use a URL
180+ </ label >
181+ < Input
182+ autoCapitalize = "none"
183+ autoComplete = "off"
184+ autoCorrect = "off"
185+ className = { cn (
186+ "h-7 min-w-0 flex-1 border-0 bg-transparent px-0 py-0 text-sm shadow-none outline-none focus-visible:ring-0" ,
187+ isAvatarUrlInputFocused
188+ ? "placeholder:text-muted-foreground/55"
189+ : "placeholder:text-popover-foreground" ,
190+ ) }
191+ disabled = { disabled || isUploading }
192+ id = "agent-avatar-url"
193+ onBlur = { ( ) => setIsAvatarUrlInputFocused ( false ) }
194+ onChange = { ( event ) => setAvatarUrlDraft ( event . target . value ) }
195+ onFocus = { ( ) => setIsAvatarUrlInputFocused ( true ) }
196+ placeholder = { isAvatarUrlInputFocused ? "https://..." : "Use a URL" }
197+ spellCheck = { false }
198+ value = { avatarUrlDraft }
199+ />
200+ < AnimatePresence initial = { false } >
201+ { hasAvatarUrlDraft ? (
202+ < motion . div
203+ animate = { { opacity : 1 , scale : 1 , width : "auto" } }
204+ className = "overflow-hidden"
205+ exit = { { opacity : 0 , scale : 0.96 , width : 0 } }
206+ initial = { { opacity : 0 , scale : 0.96 , width : 0 } }
207+ key = "apply-avatar-url"
208+ transition = { applyButtonTransition }
209+ >
210+ < Button
211+ className = "h-7 px-2 text-xs"
212+ disabled = { disabled || isUploading }
213+ size = "xs"
214+ type = "submit"
215+ >
216+ Apply
217+ </ Button >
218+ </ motion . div >
219+ ) : null }
220+ </ AnimatePresence >
221+ </ form >
222+ { hasAvatar && onClearAvatar ? (
223+ < button
224+ className = "flex min-h-9 w-full items-center rounded-lg px-2.5 text-left text-sm text-destructive outline-hidden transition-colors duration-150 ease-out hover:bg-destructive/10 focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
225+ disabled = { disabled || isUploading }
226+ onClick = { ( ) => {
227+ onClearAvatar ( ) ;
228+ setIsAvatarMenuOpen ( false ) ;
229+ } }
230+ type = "button"
231+ >
232+ Remove avatar
233+ </ button >
234+ ) : null }
235+ </ PopoverContent >
236+ ) ;
237+
112238 return (
113239 < div className = "mx-auto w-full max-w-[220px] lg:sticky lg:top-0" >
114240 < fieldset
115241 aria-label = "Agent avatar preview"
116242 className = { cn (
117- "group/avatar-preview relative m-0 aspect-[4/5] min-h-[240px ] min-w-0 overflow-hidden rounded-xl border border-border/70 bg-muted/50 p-0 shadow-xs transition-[background-color,border-color,box-shadow] duration-150" ,
243+ "group/avatar-preview relative m-0 flex min-h-[190px ] min-w-0 flex-col items-center justify-center gap-3 rounded-xl border border-transparent p-0 transition-[background-color,border-color,box-shadow] duration-150" ,
118244 isDragOverAvatar &&
119245 "border-dashed border-primary/70 bg-primary/5 ring-2 ring-primary/15" ,
120246 ) }
@@ -131,50 +257,100 @@ export function AgentCreationPreview({
131257 type = "file"
132258 />
133259
134- < div className = "absolute inset-0 flex items-center justify-center" >
135- < ProfileAvatar
136- avatarUrl = { avatarUrl }
137- className = "h-36 w-36 text-4xl"
138- label = { label }
139- />
260+ < div className = "relative h-36 w-36" >
261+ { hasAvatar ? (
262+ < >
263+ < svg
264+ aria-hidden = "true"
265+ className = "pointer-events-none absolute inset-0 h-full w-full"
266+ fill = "none"
267+ height = "144"
268+ viewBox = "0 0 144 144"
269+ width = "144"
270+ xmlns = "http://www.w3.org/2000/svg"
271+ >
272+ < clipPath clipPathUnits = "userSpaceOnUse" id = { avatarEditClipId } >
273+ < path
274+ clipRule = "evenodd"
275+ d = "M100.734 83.3298C102.415 84.1574 104.616 83.8757 105.495 82.2207C109.647 74.3981 112 65.4738 112 56C112 25.0721 86.9279 0 56 0C25.0721 0 0 25.0721 0 56C0 86.9279 25.0721 112 56 112C65.4738 112 74.3981 109.647 82.2207 105.495C83.8757 104.616 84.1574 102.415 83.3298 100.734C82.4783 99.0047 82 97.0582 82 95C82 87.8203 87.8203 82 95 82C97.0582 82 99.0047 82.4783 100.734 83.3298Z"
276+ fillRule = "evenodd"
277+ transform = "translate(-25.875 -25.875) scale(1.575)"
278+ />
279+ </ clipPath >
280+ </ svg >
281+
282+ < div className = "relative h-full w-full" style = { avatarClipStyle } >
283+ < ProfileAvatar
284+ avatarUrl = { avatarUrl }
285+ className = { cn (
286+ "h-full w-full text-4xl transition-shadow duration-150" ,
287+ isDragOverAvatar && "ring-2 ring-primary/30" ,
288+ ) }
289+ label = { label }
290+ />
291+ </ div >
292+
293+ < div className = "absolute bottom-0 right-0 z-10 flex h-[42px] w-[42px] items-center justify-center rounded-full bg-background" >
294+ < Popover
295+ open = { isAvatarMenuOpen }
296+ onOpenChange = { setIsAvatarMenuOpen }
297+ >
298+ < PopoverTrigger asChild >
299+ < button
300+ aria-label = "Edit avatar"
301+ className = "flex h-9 w-9 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground shadow-lg transition-[background-color,scale] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90 disabled:hover:scale-100"
302+ disabled = { disabled || isUploading }
303+ title = "Edit avatar"
304+ type = "button"
305+ >
306+ { isUploading ? (
307+ < Spinner
308+ aria-label = "Uploading avatar"
309+ className = "h-4 w-4 border-2"
310+ />
311+ ) : (
312+ < Pencil className = "h-4 w-4" />
313+ ) }
314+ </ button >
315+ </ PopoverTrigger >
316+ { avatarMenuContent }
317+ </ Popover >
318+ </ div >
319+ </ >
320+ ) : (
321+ < Popover open = { isAvatarMenuOpen } onOpenChange = { setIsAvatarMenuOpen } >
322+ < PopoverTrigger asChild >
323+ < button
324+ aria-label = "Add avatar"
325+ className = { cn (
326+ "flex h-full w-full items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-primary shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-70" ,
327+ isDragOverAvatar &&
328+ "border-primary/70 bg-primary/5 ring-2 ring-primary/15" ,
329+ ) }
330+ disabled = { disabled || isUploading }
331+ title = "Add avatar"
332+ type = "button"
333+ >
334+ { isUploading ? (
335+ < Spinner
336+ aria-label = "Uploading avatar"
337+ className = "h-4 w-4 border-2"
338+ />
339+ ) : (
340+ < Plus aria-hidden = "true" className = "h-14 w-14" />
341+ ) }
342+ </ button >
343+ </ PopoverTrigger >
344+ { avatarMenuContent }
345+ </ Popover >
346+ ) }
140347 </ div >
141348
142349 { uploadErrorMessage ? (
143- < p className = "absolute inset-x-3 bottom-12 rounded-md bg-background/95 px-2 py-1 text-center text-xs text-destructive shadow-xs" >
350+ < p className = "max-w-full rounded-md bg-background/95 px-2 py-1 text-center text-xs text-destructive shadow-xs" >
144351 { uploadErrorMessage }
145352 </ p >
146353 ) : null }
147-
148- < div className = "absolute inset-x-3 bottom-3 flex justify-center gap-2" >
149- < button
150- className = "inline-flex h-8 translate-y-1 items-center justify-center gap-1.5 rounded-full border border-border/70 bg-background/90 px-3 text-xs font-medium text-foreground opacity-0 shadow-xs transition-[background-color,opacity,transform] duration-150 hover:bg-muted focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover/avatar-preview:translate-y-0 group-hover/avatar-preview:opacity-100 group-focus-within/avatar-preview:translate-y-0 group-focus-within/avatar-preview:opacity-100"
151- disabled = { disabled || isUploading }
152- onClick = { ( ) => {
153- clearUploadError ( ) ;
154- openUploadPicker ( ) ;
155- } }
156- type = "button"
157- >
158- { isUploading ? (
159- < Spinner className = "h-3.5 w-3.5 border-2" />
160- ) : (
161- < Upload className = "h-3.5 w-3.5" />
162- ) }
163- { isUploading ? "Uploading..." : "Edit avatar" }
164- </ button >
165- { avatarUrl && onClearAvatar ? (
166- < button
167- className = "inline-flex h-8 translate-y-1 items-center justify-center gap-1.5 rounded-full border border-border/70 bg-background/90 px-3 text-xs font-medium text-foreground opacity-0 shadow-xs transition-[background-color,opacity,transform] duration-150 hover:bg-muted focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover/avatar-preview:translate-y-0 group-hover/avatar-preview:opacity-100 group-focus-within/avatar-preview:translate-y-0 group-focus-within/avatar-preview:opacity-100"
168- disabled = { disabled || isUploading }
169- onClick = { onClearAvatar }
170- title = "Remove avatar"
171- type = "button"
172- >
173- < X className = "h-3.5 w-3.5" />
174- Remove
175- </ button >
176- ) : null }
177- </ div >
178354 </ fieldset >
179355 </ div >
180356 ) ;
0 commit comments