@@ -13,6 +13,7 @@ import {
1313} from "@/lib/tanstack/mutation/content" ;
1414import { useGetCategories } from "@/lib/tanstack/query/category" ;
1515import { useGetContentById } from "@/lib/tanstack/query/content" ;
16+ import { useGetTags } from "@/lib/tanstack/query/tag" ;
1617import { contentSchema , type ContentSchemaType } from "@/schemas/content" ;
1718import { infoToast , successToast } from "@/utils/toast" ;
1819import { zodResolver } from "@hookform/resolvers/zod" ;
@@ -46,6 +47,8 @@ const DEFAULT_STATE: CreateContentState = {
4647
4748export default function CreateContent ( ) {
4849 const [ isCategoryOpen , setIsCategoryOpen ] = useState < boolean > ( false ) ;
50+ const [ isTagDropdownOpen , setIsTagDropdownOpen ] = useState < boolean > ( false ) ;
51+ const [ newTag , setNewTag ] = useState < string > ( "" ) ;
4952
5053 const { state } = useLocation ( ) as { state : CreateContentState } ;
5154 const [ searchParams ] = useSearchParams ( ) ;
@@ -60,6 +63,10 @@ export default function CreateContent() {
6063 setIsCategoryOpen ( false ) ;
6164 } ) ;
6265
66+ const [ tagFormRef ] = useOutsideClick < HTMLFormElement > ( ( ) => {
67+ setIsTagDropdownOpen ( false ) ;
68+ } ) ;
69+
6370 const {
6471 mutateAsync : mutateFinishDetailSaveContent ,
6572 isPending : isPendingFinishDetailSaveContent ,
@@ -72,6 +79,7 @@ export default function CreateContent() {
7279 useUpdateContent ( ) ;
7380
7481 const { data : userCategories } = useGetCategories ( ) ;
82+ const { data : allTags } = useGetTags ( ) ;
7583
7684 const isBadRequest = ! id && ! state ;
7785
@@ -118,6 +126,12 @@ export default function CreateContent() {
118126 const watchedCategory = watch ( "category" ) ;
119127 const watchedThumbnail = watch ( "thumbnail" ) ;
120128
129+ // 필터링된 태그 계산
130+ const filteredTags = allTags ?. filter (
131+ ( tag ) =>
132+ ! watchedTags . includes ( tag . name ) && tag . name . toLowerCase ( ) . includes ( newTag . toLowerCase ( ) )
133+ ) ;
134+
121135 const isYoutubeUrl = watch ( "url" ) . includes ( "youtube.com" ) ;
122136 const isPending =
123137 isPendingFinishDetailSaveContent ||
@@ -189,21 +203,19 @@ export default function CreateContent() {
189203 const onAddTag = ( e : React . FormEvent < HTMLFormElement > ) => {
190204 e . preventDefault ( ) ;
191205
192- const newTag = e . currentTarget . querySelector ( "input" ) ?. value ;
193-
194- const trimmedTag = newTag ?. trim ( ) ;
195-
196- if ( ! trimmedTag ) {
197- return infoToast ( "태그를 입력해주세요." ) ;
198- }
206+ const trimmedTag = newTag . trim ( ) ;
199207
200208 if ( watchedTags . includes ( trimmedTag ) ) {
201209 return infoToast ( "이미 존재하는 태그입니다." ) ;
202210 }
203211
204- const newTags = [ ...watchedTags , trimmedTag ] ;
212+ onUpdateTag ( trimmedTag ) ;
213+ } ;
214+
215+ const onUpdateTag = ( tag : string ) => {
216+ const newTags = [ ...watchedTags , tag ] ;
205217 setValue ( "tags" , newTags ) ;
206- e . currentTarget . reset ( ) ;
218+ setNewTag ( "" ) ;
207219 } ;
208220
209221 const onRemoveTag = ( index : number ) => {
@@ -323,54 +335,43 @@ export default function CreateContent() {
323335 ) }
324336 </ div >
325337
326- { /* 요약 */ }
327- < div >
328- < h2 className = "text-muted-foreground mb-2 text-sm font-medium" > 요약</ h2 >
329- < textarea
330- { ...register ( "summary" ) }
331- placeholder = "요약을 입력하세요"
332- className = "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-[100px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
333- />
334- { errors . summary && (
335- < p className = "text-destructive mt-1 text-xs" > { errors . summary . message } </ p >
336- ) }
337- </ div >
338-
339- { /* 메모 */ }
340- < div >
341- < h2 className = "text-muted-foreground mb-2 text-sm font-medium" > 메모</ h2 >
342- < textarea
343- { ...register ( "memo" ) }
344- placeholder = "메모를 입력하세요"
345- className = "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
346- />
347- { errors . memo && < p className = "text-destructive mt-1 text-xs" > { errors . memo . message } </ p > }
348- </ div >
349-
350338 { /* 태그 */ }
351339 < div >
352340 < h2 className = "text-muted-foreground mb-2 text-sm font-medium" > 태그</ h2 >
353341 < div className = "space-y-3" >
354342 { /* 기존 태그들 */ }
355- < div className = "flex flex-wrap gap-2" >
356- { watchedTags . map ( ( tag , index ) => (
357- < button
358- key = { index }
359- type = "button"
360- className = "bg-primary/10 text-primary hover:bg-primary/20 flex items-center space-x-1 rounded-full px-3 py-1 text-sm transition-all duration-200 hover:scale-105"
361- onClick = { ( ) => onRemoveTag ( index ) }
362- aria-label = { `${ tag } 태그 제거` }
363- >
364- < span > { tag } </ span >
365- < X className = "h-3 w-3" />
366- </ button >
367- ) ) }
368- </ div >
343+ { watchedTags . length > 0 && (
344+ < div className = "flex flex-wrap gap-2" >
345+ { watchedTags . map ( ( tag , index ) => (
346+ < button
347+ key = { index }
348+ type = "button"
349+ className = "bg-primary/10 text-primary hover:bg-primary/20 flex items-center space-x-1 rounded-full px-3 py-1 text-sm transition-all duration-200 hover:scale-105"
350+ onClick = { ( ) => onRemoveTag ( index ) }
351+ aria-label = { `${ tag } 태그 제거` }
352+ >
353+ < span > { tag } </ span >
354+ < X className = "h-3 w-3" />
355+ </ button >
356+ ) ) }
357+ </ div >
358+ ) }
369359
370360 { /* 새 태그 추가 */ }
371361 < div className = "space-y-2" >
372- < form className = "flex space-x-2" onSubmit = { onAddTag } >
373- < Input ref = { tagInputRef } placeholder = "새 태그 입력" className = "flex-1" />
362+ < form
363+ ref = { tagFormRef }
364+ className = "relative flex items-center gap-2"
365+ onSubmit = { onAddTag }
366+ >
367+ < Input
368+ ref = { tagInputRef }
369+ value = { newTag }
370+ onChange = { ( e ) => setNewTag ( e . target . value ) }
371+ placeholder = "새 태그 입력"
372+ className = "flex-1"
373+ onFocus = { ( ) => setIsTagDropdownOpen ( true ) }
374+ />
374375 < Button
375376 type = "submit"
376377 size = "sm"
@@ -380,6 +381,26 @@ export default function CreateContent() {
380381 >
381382 < Plus />
382383 </ Button >
384+ { isTagDropdownOpen && (
385+ < div className = "absolute -bottom-2 max-h-40 w-full translate-y-full overflow-auto rounded-md border bg-white shadow-lg" >
386+ { ! filteredTags || filteredTags . length === 0 ? (
387+ < p className = "text-muted-foreground block w-full px-3 py-2 text-left" >
388+ 태그가 없어요.
389+ </ p >
390+ ) : (
391+ filteredTags . map ( ( tag ) => (
392+ < button
393+ key = { `${ tag . id } -${ tag . name } ` }
394+ type = "button"
395+ className = "hover:bg-accent text-foreground line-clamp-1 block w-full px-3 py-2 text-left"
396+ onClick = { ( ) => onUpdateTag ( tag . name ) }
397+ >
398+ < span > { tag . name } </ span >
399+ </ button >
400+ ) )
401+ ) }
402+ </ div >
403+ ) }
383404 </ form >
384405 < p className = "text-muted-foreground text-xs" >
385406 Enter 혹은 + 버튼을 통해 태그를 추가할 수 있어요.
@@ -388,6 +409,30 @@ export default function CreateContent() {
388409 </ div >
389410 { errors . tags && < p className = "text-destructive mt-1 text-xs" > { errors . tags . message } </ p > }
390411 </ div >
412+
413+ { /* 요약 */ }
414+ < div >
415+ < h2 className = "text-muted-foreground mb-2 text-sm font-medium" > 요약</ h2 >
416+ < textarea
417+ { ...register ( "summary" ) }
418+ placeholder = "요약을 입력하세요"
419+ className = "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-[100px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
420+ />
421+ { errors . summary && (
422+ < p className = "text-destructive mt-1 text-xs" > { errors . summary . message } </ p >
423+ ) }
424+ </ div >
425+
426+ { /* 메모 */ }
427+ < div >
428+ < h2 className = "text-muted-foreground mb-2 text-sm font-medium" > 메모</ h2 >
429+ < textarea
430+ { ...register ( "memo" ) }
431+ placeholder = "메모를 입력하세요"
432+ className = "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
433+ />
434+ { errors . memo && < p className = "text-destructive mt-1 text-xs" > { errors . memo . message } </ p > }
435+ </ div >
391436 </ div >
392437 </ div >
393438 </ >
0 commit comments