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
95 changes: 73 additions & 22 deletions components/features/home/search/HomeSearchOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { createPortal } from "react-dom";
import { Search, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchHomeTrend, type TrendRange } from "@/lib/mock/home-search-trend";
import { searchMockResults } from "@/lib/mock/home-search-results";
import { useAuthStore } from "@/store/auth.store";
import { HomeRangeTabs } from "./HomeRangeTabs";
import { HomeTopPostsSection } from "./HomeTopPostsSection";
import { HomeTopPostsSummarySection } from "./HomeTopPostsSummarySection";
import { HomeCollectionSummarySection } from "./HomeCollectionSummarySection";
import { HomeTrendingKeywordsSection } from "./HomeTrendingKeywordsSection";
import { HomeSearchResultsSection } from "./HomeSearchResultsSection";

const normalizeTag = (value: string) =>
value.toLowerCase().replace(/\s+/g, "").replace(/[.#]/g, "");
Expand All @@ -23,6 +25,8 @@ interface HomeSearchOverlayProps {
export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
const [range, setRange] = useState<TrendRange>("week");
const [inputValue, setInputValue] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [activeItemId, setActiveItemId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
Expand All @@ -37,6 +41,8 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
clearTimeout(t);
document.body.style.overflow = "";
setInputValue("");
setDebouncedQuery("");
setActiveItemId(null);
};
}, [isOpen]);

Expand All @@ -63,9 +69,32 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {

const handleKeywordClick = useCallback((keyword: string) => {
setInputValue(keyword);
setActiveItemId(null);
inputRef.current?.focus();
}, []);

// debounce: 2글자 미만이면 즉시 초기화, 이상이면 300ms 후 반영
// setActiveItemId(null)도 함께 실행해 검색어 변경 시 열린 아코디언 초기화
useEffect(() => {
const delay = inputValue.length >= 2 ? 300 : 0;
const timer = setTimeout(() => {
setDebouncedQuery(inputValue.length >= 2 ? inputValue : "");
setActiveItemId(null);
}, delay);
return () => clearTimeout(timer);
}, [inputValue]);

const searchResults = useMemo(
() => (debouncedQuery ? searchMockResults(debouncedQuery) : []),
[debouncedQuery],
);

const isSearching = debouncedQuery.length > 0;

const handleToggleItem = useCallback((id: string) => {
setActiveItemId((prev) => (prev === id ? null : id));
}, []);

const keywordsWithInterest = useMemo(() => {
const keywords = data?.trendingKeywords ?? [];
const userTags = user?.tags;
Expand Down Expand Up @@ -118,6 +147,18 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
placeholder="관심 주제나 기술을 검색해 보세요..."
className="flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground md:text-lg"
/>
{inputValue && (
<button
onClick={() => {
setInputValue("");
inputRef.current?.focus();
}}
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
Expand All @@ -133,34 +174,44 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
{/* 스크롤 영역 — 전체 너비로 확장해 여백 포함 스크롤 가능 */}
<div className="flex-1 overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto max-w-5xl px-6 py-2 md:px-8 md:py-6">
<div className="flex flex-col gap-10">
<HomeTopPostsSection
posts={data?.topPosts ?? []}
isLoading={isLoading}
rangeLabel={rangeLabel}
/>

<HomeTopPostsSummarySection
summary={data?.topPostsSummary ?? ""}
isLoading={isLoading}
rangeLabel={rangeLabel}
{isSearching ? (
<HomeSearchResultsSection
results={searchResults}
activeItemId={activeItemId}
onToggle={handleToggleItem}
isLoading={false}
isError={false}
/>
) : (
<div className="flex flex-col gap-10">
<HomeTopPostsSection
posts={data?.topPosts ?? []}
isLoading={isLoading}
rangeLabel={rangeLabel}
/>

{showCollectionSummary && (
<HomeCollectionSummarySection
summary={data?.collectionSummary ?? ""}
<HomeTopPostsSummarySection
summary={data?.topPostsSummary ?? ""}
isLoading={isLoading}
rangeLabel={rangeLabel}
/>
)}

<HomeTrendingKeywordsSection
keywords={keywordsWithInterest}
isLoading={isLoading}
rangeLabel={rangeLabel}
onKeywordClick={handleKeywordClick}
/>
</div>
{showCollectionSummary && (
<HomeCollectionSummarySection
summary={data?.collectionSummary ?? ""}
isLoading={isLoading}
rangeLabel={rangeLabel}
/>
)}

<HomeTrendingKeywordsSection
keywords={keywordsWithInterest}
isLoading={isLoading}
rangeLabel={rangeLabel}
onKeywordClick={handleKeywordClick}
/>
</div>
)}
</div>
</div>
</div>,
Expand Down
78 changes: 78 additions & 0 deletions components/features/home/search/HomeSearchResultAccordionItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Image from "next/image";
import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { SearchResultItem } from "@/types/search";

interface HomeSearchResultAccordionItemProps {
item: SearchResultItem;
isOpen: boolean;
onToggle: () => void;
}

export function HomeSearchResultAccordionItem({
item,
isOpen,
onToggle,
}: HomeSearchResultAccordionItemProps) {
return (
<div className="border-b border-border">
{/* 닫힘 상태 — 한 줄 */}
<button
onClick={onToggle}
className="flex w-full cursor-pointer items-center gap-3 py-3 text-left"
>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{item.sourceName}
</span>
<span className="flex-1 truncate text-sm font-medium text-foreground">
{item.title}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{item.publishedAt}
</span>
{isOpen ? (
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</button>

{/* 열림 상태 — 인라인 확장 */}
{isOpen && (
<div className="flex gap-4 pb-4 pl-1">
{item.thumbnailUrl && (
<div className="relative h-24 w-36 shrink-0 overflow-hidden rounded-lg">
<Image
src={item.thumbnailUrl}
alt={item.title}
fill
className="object-cover"
/>
</div>
)}
<div className="flex flex-col gap-2">
<p className="text-sm leading-relaxed text-muted-foreground">
{item.summary}
</p>
<div className="flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{tag}
</span>
))}
</div>
<Link
href={item.url}
className="mt-1 w-fit text-xs font-medium text-primary hover:underline"
>
전체 글 보기 →
</Link>
</div>
</div>
)}
</div>
);
}
78 changes: 78 additions & 0 deletions components/features/home/search/HomeSearchResultsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Skeleton } from "@/components/ui/skeleton";
import type { SearchResultItem } from "@/types/search";
import { HomeSearchResultAccordionItem } from "./HomeSearchResultAccordionItem";

interface HomeSearchResultsSectionProps {
results: SearchResultItem[];
activeItemId: string | null;
onToggle: (id: string) => void;
isLoading?: boolean;
isError?: boolean;
}

function SearchResultSkeleton() {
return (
<div className="border-b border-border py-3">
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-14 shrink-0 rounded" />
<Skeleton className="h-4 flex-1 rounded" />
<Skeleton className="h-4 w-16 shrink-0 rounded" />
<Skeleton className="h-4 w-4 shrink-0 rounded" />
</div>
</div>
);
}

export function HomeSearchResultsSection({
results,
activeItemId,
onToggle,
isLoading = false,
isError = false,
}: HomeSearchResultsSectionProps) {
if (isLoading) {
return (
<section>
<Skeleton className="mb-3 h-5 w-24 rounded" />
{Array.from({ length: 5 }).map((_, i) => (
<SearchResultSkeleton key={i} />
))}
</section>
);
}

if (isError) {
return (
<section>
<h2 className="mb-3 text-md font-semibold text-foreground">
검색 결과
</h2>
<p className="text-sm font-medium text-muted-foreground">
검색 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.
</p>
</section>
);
}

return (
<section>
<h2 className="mb-3 text-md font-semibold text-foreground">
검색 결과 ({results.length})
</h2>
{results.length === 0 ? (
<p className="text-sm text-muted-foreground">검색 결과가 없습니다.</p>
) : (
<div>
{results.map((item) => (
<HomeSearchResultAccordionItem
key={item.id}
item={item}
isOpen={activeItemId === item.id}
onToggle={() => onToggle(item.id)}
/>
))}
</div>
)}
</section>
);
}
Loading
Loading