Skip to content

Commit 986515f

Browse files
committed
Merge branch 'dev'
2 parents b9edac5 + 9d5af1c commit 986515f

21 files changed

Lines changed: 611 additions & 371 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const TAG = {
2+
GET_TAGS: "get-tags",
3+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { TAG } from "@/constants/tag";
2+
import { getTags } from "@/services/tag";
3+
import { useQuery } from "@tanstack/react-query";
4+
5+
export const useGetTags = () => {
6+
return useQuery({
7+
queryKey: [TAG.GET_TAGS],
8+
queryFn: getTags,
9+
select: (data) => data.result,
10+
});
11+
};

apps/extension/src/pages/create-content.tsx

Lines changed: 94 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "@/lib/tanstack/mutation/content";
1414
import { useGetCategories } from "@/lib/tanstack/query/category";
1515
import { useGetContentById } from "@/lib/tanstack/query/content";
16+
import { useGetTags } from "@/lib/tanstack/query/tag";
1617
import { contentSchema, type ContentSchemaType } from "@/schemas/content";
1718
import { infoToast, successToast } from "@/utils/toast";
1819
import { zodResolver } from "@hookform/resolvers/zod";
@@ -46,6 +47,8 @@ const DEFAULT_STATE: CreateContentState = {
4647

4748
export 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
</>

apps/extension/src/services/tag.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { BaseResponseDTO, TagDTO } from "@repo/types";
2+
3+
import { api } from ".";
4+
5+
export const getTags = async (): Promise<BaseResponseDTO<TagDTO[]>> => {
6+
return api.get("tags").json();
7+
};

apps/web/public/og-image.png

35.1 KB
Loading

0 commit comments

Comments
 (0)