Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/extension/src/components/create-content/select-category.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useState } from "react";

import { useGetCategories } from "@/lib/tanstack/query/category";
import type { ContentSchemaType } from "@/schemas/content";
import { useOutsideClick } from "@linkyboard/hooks";

import { ChevronDown } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";

interface SelectCategoryProps {
category: string;
form: UseFormReturn<ContentSchemaType>;
}

export default function SelectCategory({ category, form }: SelectCategoryProps) {
const [isCategoryOpen, setIsCategoryOpen] = useState(false);

const [dropdownRef] = useOutsideClick<HTMLDivElement>(() => {
setIsCategoryOpen(false);
});

const { data: userCategories } = useGetCategories();

const categories = [...(category ? [category] : []), ...(userCategories || [])];

const watchedCategory = form.watch("category");

const onCategorySelect = (category: string) => {
form.setValue("category", category.trim());
setIsCategoryOpen(false);
};

return (
<div>
<h2 className="text-muted-foreground mb-2 text-sm font-medium">카테고리</h2>
<div ref={dropdownRef} className="relative">
<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">
<input
type="text"
placeholder="카테고리를 입력하세요"
className="w-full outline-none"
onFocus={() => setIsCategoryOpen(true)}
{...form.register("category")}
/>
<button type="button" onClick={() => setIsCategoryOpen((prev) => !prev)}>
<ChevronDown
className={`h-4 w-4 transition-transform ${isCategoryOpen ? "rotate-180" : ""}`}
/>
</button>
</div>

{isCategoryOpen && (
<div className="bg-background absolute z-10 mt-5 max-h-52 w-full overflow-y-auto rounded-md border shadow-lg">
<button
type="button"
onClick={() => onCategorySelect(watchedCategory)}
disabled={!watchedCategory.trim()}
className="hover:bg-accent text-foreground block w-full px-3 py-2 text-left text-sm"
>
{watchedCategory ? `"${watchedCategory}" 사용하기` : "최소 1글자 입력해주세요."}
</button>
{categories.map((category) => (
<button
key={category}
type="button"
onClick={() => onCategorySelect(category)}
className={`hover:bg-accent block w-full px-3 py-2 text-left text-sm ${
watchedCategory === category
? "bg-accent text-accent-foreground"
: "text-foreground"
}`}
>
{category}
</button>
))}
</div>
)}
</div>
{form.formState.errors.category && (
<p className="text-destructive mt-1 text-xs">{form.formState.errors.category.message}</p>
)}
</div>
);
}
129 changes: 129 additions & 0 deletions apps/extension/src/components/create-content/select-tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useRef, useState } from "react";

import { useGetTags } from "@/lib/tanstack/query/tag";
import type { ContentSchemaType } from "@/schemas/content";
import { Button, infoToast, Input } from "@linkyboard/components";
import { useOutsideClick } from "@linkyboard/hooks";

import { Plus, X } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";

interface SelectTagProps {
form: UseFormReturn<ContentSchemaType>;
}

export default function SelectTag({ form }: SelectTagProps) {
const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false);
const [newTag, setNewTag] = useState("");

const tagInputRef = useRef<HTMLInputElement>(null);

const [tagFormRef] = useOutsideClick<HTMLFormElement>(() => {
setIsTagDropdownOpen(false);
});

const { data: allTags } = useGetTags();

const watchedTags = form.watch("tags");

// 필터링된 태그 계산
const filteredTags = allTags?.filter(
(tag) =>
!watchedTags.includes(tag.name) && tag.name.toLowerCase().includes(newTag.toLowerCase())
);

const onAddTag = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

const trimmedTag = newTag.trim();

if (watchedTags.includes(trimmedTag)) {
return infoToast("이미 존재하는 태그입니다.");
}

onUpdateTag(trimmedTag);
};

const onUpdateTag = (tag: string) => {
const newTags = [...watchedTags, tag];
form.setValue("tags", newTags);
setNewTag("");
};

const onRemoveTag = (index: number) => {
const newTags = watchedTags.filter((_, i) => i !== index);
form.setValue("tags", newTags);
tagInputRef.current?.focus();
};

return (
<div>
<h2 className="text-muted-foreground mb-2 text-sm font-medium">태그</h2>
<div className="space-y-3">
{/* 기존 태그들 */}
{watchedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{watchedTags.map((tag, index) => (
<button
key={index}
type="button"
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"
onClick={() => onRemoveTag(index)}
aria-label={`${tag} 태그 제거`}
>
<span>{tag}</span>
<X className="h-3 w-3" />
</button>
))}
</div>
)}

{/* 새 태그 추가 */}
<div className="space-y-2">
<form ref={tagFormRef} className="relative flex items-center gap-2" onSubmit={onAddTag}>
<Input
ref={tagInputRef}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="새 태그 입력"
className="flex-1"
onFocus={() => setIsTagDropdownOpen(true)}
/>
<Button
type="submit"
size="sm"
variant="outline"
aria-label="태그 추가"
className="aspect-square h-full shrink-0"
>
<Plus />
</Button>
{isTagDropdownOpen && (
<div className="absolute -bottom-2 max-h-40 w-full translate-y-full overflow-auto rounded-md border bg-white shadow-lg">
{!filteredTags || filteredTags.length === 0 ? (
<p className="text-muted-foreground block w-full px-3 py-2 text-left">
태그가 없어요.
</p>
) : (
filteredTags.map((tag) => (
<button
key={`${tag.id}-${tag.name}`}
type="button"
className="hover:bg-accent text-foreground line-clamp-1 block w-full px-3 py-2 text-left"
onClick={() => onUpdateTag(tag.name)}
>
<span>{tag.name}</span>
</button>
))
)}
</div>
)}
</form>
<p className="text-muted-foreground text-xs">
Enter 혹은 + 버튼을 통해 태그를 추가할 수 있어요.
</p>
</div>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions apps/extension/src/lib/tanstack/query/category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export const useGetCategories = () => {
queryKey: [CATEGORY.GET_CATEGORIES],
queryFn: getCategories,
select: (data) => data.result.map((category) => category.name),
staleTime: 1000 * 60,
});
};
1 change: 1 addition & 0 deletions apps/extension/src/lib/tanstack/query/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export const useGetTags = () => {
queryKey: [TAG.GET_TAGS],
queryFn: getTags,
select: (data) => data.result,
staleTime: 1000 * 60,
});
};
Loading