Skip to content

Commit cc8aba6

Browse files
authored
[IMPROVE] extension 리팩토링 (#40)
* feat: 상세 저장 혹은 빠른 저장 중일 때 로딩 토스트 띄워주도록 구현 * feat: 웹 페이지 바로 갈 수 있도록 버튼 추가 * feat: 상세 저장 페이지에 있는 태그, 카테고리 선택 컴포넌트 분리 * feat: 요약 성공 시 카테고리와 태그 prefetchQuery 적용
1 parent 69dbdda commit cc8aba6

7 files changed

Lines changed: 371 additions & 263 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useState } from "react";
2+
3+
import { useGetCategories } from "@/lib/tanstack/query/category";
4+
import type { ContentSchemaType } from "@/schemas/content";
5+
import { useOutsideClick } from "@linkyboard/hooks";
6+
7+
import { ChevronDown } from "lucide-react";
8+
import type { UseFormReturn } from "react-hook-form";
9+
10+
interface SelectCategoryProps {
11+
category: string;
12+
form: UseFormReturn<ContentSchemaType>;
13+
}
14+
15+
export default function SelectCategory({ category, form }: SelectCategoryProps) {
16+
const [isCategoryOpen, setIsCategoryOpen] = useState(false);
17+
18+
const [dropdownRef] = useOutsideClick<HTMLDivElement>(() => {
19+
setIsCategoryOpen(false);
20+
});
21+
22+
const { data: userCategories } = useGetCategories();
23+
24+
const categories = [...(category ? [category] : []), ...(userCategories || [])];
25+
26+
const watchedCategory = form.watch("category");
27+
28+
const onCategorySelect = (category: string) => {
29+
form.setValue("category", category.trim());
30+
setIsCategoryOpen(false);
31+
};
32+
33+
return (
34+
<div>
35+
<h2 className="text-muted-foreground mb-2 text-sm font-medium">카테고리</h2>
36+
<div ref={dropdownRef} className="relative">
37+
<div className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
38+
<input
39+
type="text"
40+
placeholder="카테고리를 입력하세요"
41+
className="w-full outline-none"
42+
onFocus={() => setIsCategoryOpen(true)}
43+
{...form.register("category")}
44+
/>
45+
<button type="button" onClick={() => setIsCategoryOpen((prev) => !prev)}>
46+
<ChevronDown
47+
className={`h-4 w-4 transition-transform ${isCategoryOpen ? "rotate-180" : ""}`}
48+
/>
49+
</button>
50+
</div>
51+
52+
{isCategoryOpen && (
53+
<div className="bg-background absolute z-10 mt-5 max-h-52 w-full overflow-y-auto rounded-md border shadow-lg">
54+
<button
55+
type="button"
56+
onClick={() => onCategorySelect(watchedCategory)}
57+
disabled={!watchedCategory.trim()}
58+
className="hover:bg-accent text-foreground block w-full px-3 py-2 text-left text-sm"
59+
>
60+
{watchedCategory ? `"${watchedCategory}" 사용하기` : "최소 1글자 입력해주세요."}
61+
</button>
62+
{categories.map((category) => (
63+
<button
64+
key={category}
65+
type="button"
66+
onClick={() => onCategorySelect(category)}
67+
className={`hover:bg-accent block w-full px-3 py-2 text-left text-sm ${
68+
watchedCategory === category
69+
? "bg-accent text-accent-foreground"
70+
: "text-foreground"
71+
}`}
72+
>
73+
{category}
74+
</button>
75+
))}
76+
</div>
77+
)}
78+
</div>
79+
{form.formState.errors.category && (
80+
<p className="text-destructive mt-1 text-xs">{form.formState.errors.category.message}</p>
81+
)}
82+
</div>
83+
);
84+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useRef, useState } from "react";
2+
3+
import { useGetTags } from "@/lib/tanstack/query/tag";
4+
import type { ContentSchemaType } from "@/schemas/content";
5+
import { Button, infoToast, Input } from "@linkyboard/components";
6+
import { useOutsideClick } from "@linkyboard/hooks";
7+
8+
import { Plus, X } from "lucide-react";
9+
import type { UseFormReturn } from "react-hook-form";
10+
11+
interface SelectTagProps {
12+
form: UseFormReturn<ContentSchemaType>;
13+
}
14+
15+
export default function SelectTag({ form }: SelectTagProps) {
16+
const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false);
17+
const [newTag, setNewTag] = useState("");
18+
19+
const tagInputRef = useRef<HTMLInputElement>(null);
20+
21+
const [tagFormRef] = useOutsideClick<HTMLFormElement>(() => {
22+
setIsTagDropdownOpen(false);
23+
});
24+
25+
const { data: allTags } = useGetTags();
26+
27+
const watchedTags = form.watch("tags");
28+
29+
// 필터링된 태그 계산
30+
const filteredTags = allTags?.filter(
31+
(tag) =>
32+
!watchedTags.includes(tag.name) && tag.name.toLowerCase().includes(newTag.toLowerCase())
33+
);
34+
35+
const onAddTag = (e: React.FormEvent<HTMLFormElement>) => {
36+
e.preventDefault();
37+
38+
const trimmedTag = newTag.trim();
39+
40+
if (watchedTags.includes(trimmedTag)) {
41+
return infoToast("이미 존재하는 태그입니다.");
42+
}
43+
44+
onUpdateTag(trimmedTag);
45+
};
46+
47+
const onUpdateTag = (tag: string) => {
48+
const newTags = [...watchedTags, tag];
49+
form.setValue("tags", newTags);
50+
setNewTag("");
51+
};
52+
53+
const onRemoveTag = (index: number) => {
54+
const newTags = watchedTags.filter((_, i) => i !== index);
55+
form.setValue("tags", newTags);
56+
tagInputRef.current?.focus();
57+
};
58+
59+
return (
60+
<div>
61+
<h2 className="text-muted-foreground mb-2 text-sm font-medium">태그</h2>
62+
<div className="space-y-3">
63+
{/* 기존 태그들 */}
64+
{watchedTags.length > 0 && (
65+
<div className="flex flex-wrap gap-2">
66+
{watchedTags.map((tag, index) => (
67+
<button
68+
key={index}
69+
type="button"
70+
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"
71+
onClick={() => onRemoveTag(index)}
72+
aria-label={`${tag} 태그 제거`}
73+
>
74+
<span>{tag}</span>
75+
<X className="h-3 w-3" />
76+
</button>
77+
))}
78+
</div>
79+
)}
80+
81+
{/* 새 태그 추가 */}
82+
<div className="space-y-2">
83+
<form ref={tagFormRef} className="relative flex items-center gap-2" onSubmit={onAddTag}>
84+
<Input
85+
ref={tagInputRef}
86+
value={newTag}
87+
onChange={(e) => setNewTag(e.target.value)}
88+
placeholder="새 태그 입력"
89+
className="flex-1"
90+
onFocus={() => setIsTagDropdownOpen(true)}
91+
/>
92+
<Button
93+
type="submit"
94+
size="sm"
95+
variant="outline"
96+
aria-label="태그 추가"
97+
className="aspect-square h-full shrink-0"
98+
>
99+
<Plus />
100+
</Button>
101+
{isTagDropdownOpen && (
102+
<div className="absolute -bottom-2 max-h-40 w-full translate-y-full overflow-auto rounded-md border bg-white shadow-lg">
103+
{!filteredTags || filteredTags.length === 0 ? (
104+
<p className="text-muted-foreground block w-full px-3 py-2 text-left">
105+
태그가 없어요.
106+
</p>
107+
) : (
108+
filteredTags.map((tag) => (
109+
<button
110+
key={`${tag.id}-${tag.name}`}
111+
type="button"
112+
className="hover:bg-accent text-foreground line-clamp-1 block w-full px-3 py-2 text-left"
113+
onClick={() => onUpdateTag(tag.name)}
114+
>
115+
<span>{tag.name}</span>
116+
</button>
117+
))
118+
)}
119+
</div>
120+
)}
121+
</form>
122+
<p className="text-muted-foreground text-xs">
123+
Enter 혹은 + 버튼을 통해 태그를 추가할 수 있어요.
124+
</p>
125+
</div>
126+
</div>
127+
</div>
128+
);
129+
}

apps/extension/src/lib/tanstack/query/category.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export const useGetCategories = () => {
77
queryKey: [CATEGORY.GET_CATEGORIES],
88
queryFn: getCategories,
99
select: (data) => data.result.map((category) => category.name),
10+
staleTime: 1000 * 60,
1011
});
1112
};

apps/extension/src/lib/tanstack/query/tag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export const useGetTags = () => {
77
queryKey: [TAG.GET_TAGS],
88
queryFn: getTags,
99
select: (data) => data.result,
10+
staleTime: 1000 * 60,
1011
});
1112
};

0 commit comments

Comments
 (0)