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
32 changes: 22 additions & 10 deletions components/features/home/search/HomeRangeTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,46 @@
"use client";

import { cn } from "@/lib/utils";
import type { TrendRange } from "@/lib/mock/home-search-trend";
import type { TrendRange } from "@/types/search";

const TABS: { value: TrendRange; label: string }[] = [
{ value: "day", label: "" },
{ value: "week", label: "" },
{ value: "month", label: "" },
{ value: "daily", label: "일간" },
{ value: "weekly", label: "주간" },
{ value: "monthly", label: "월간" },
];

interface HomeRangeTabsProps {
range: TrendRange;
onChange: (range: TrendRange) => void;
unit: TrendRange;
onChange: (unit: TrendRange) => void;
dateLabel: string;
/** 외부에서 좌우 패딩 등 레이아웃을 제어할 때 사용 */
className?: string;
}

export function HomeRangeTabs({ range, onChange, dateLabel, className }: HomeRangeTabsProps) {
export function HomeRangeTabs({
unit,
onChange,
dateLabel,
className,
}: HomeRangeTabsProps) {
return (
<div className={cn("flex shrink-0 items-center justify-between py-2.5", className)}>
<span className="text-xs text-muted-foreground">{dateLabel || " "}</span>
<div
className={cn(
"flex shrink-0 items-center justify-between py-2.5",
className,
)}
>
<span className="text-xs text-muted-foreground font-medium">
{dateLabel || " "}
</span>
<div className="flex items-center gap-0.5 rounded-lg bg-muted p-0.5">
{TABS.map((tab) => (
<button
key={tab.value}
onClick={() => onChange(tab.value)}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
range === tab.value
unit === tab.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
Expand Down
57 changes: 37 additions & 20 deletions components/features/home/search/HomeSearchOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 { fetchHomeTrend } from "@/lib/mock/home-search-trend";
import type { TrendRange } from "@/types/search";
import { searchMockResults } from "@/lib/mock/home-search-results";
import { useAuthStore } from "@/store/auth.store";
import { HomeRangeTabs } from "./HomeRangeTabs";
Expand All @@ -22,7 +23,7 @@ interface HomeSearchOverlayProps {
}

export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
const [range, setRange] = useState<TrendRange>("week");
const [unit, setUnit] = useState<TrendRange>("weekly");
const [inputValue, setInputValue] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [activeItemId, setActiveItemId] = useState<string | null>(null);
Expand All @@ -31,7 +32,7 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);

// 배경 스크롤 잠금 + 검색 input 포커스
// range 초기화는 별도 effect 불필요 — isOpen=false 시 컴포넌트가 언마운트되어 useState 초기값("week")으로 자동 리셋
// unit 초기화는 별도 effect 불필요 — isOpen=false 시 컴포넌트가 언마운트되어 useState 초기값("weekly")으로 자동 리셋
useEffect(() => {
if (!isOpen) return;
document.body.style.overflow = "hidden";
Expand All @@ -56,14 +57,14 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
}, [isOpen, onClose]);

const { data, isLoading } = useQuery({
queryKey: ["homeSearchTrend", { range }],
queryFn: () => fetchHomeTrend(range),
queryKey: ["trends", "analysis", unit],
queryFn: () => fetchHomeTrend(unit),
staleTime: 5 * 60 * 1000,
enabled: isOpen,
});

const handleRangeChange = useCallback((next: TrendRange) => {
setRange(next);
const handleUnitChange = useCallback((next: TrendRange) => {
setUnit(next);
}, []);

const handleKeywordClick = useCallback((keyword: string) => {
Expand Down Expand Up @@ -95,29 +96,43 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
}, []);

const keywordsWithInterest = useMemo(() => {
const keywords = data?.trendingKeywords ?? [];
const keywords = data?.trendingTags ?? [];
const userTags = user?.tags;
if (!isAuthenticated || !userTags?.length) return keywords;
const userTagsNormalized = new Set(userTags.map(normalizeTag));
return keywords.map((item) => ({
...item,
isMyInterest: userTagsNormalized.has(normalizeTag(item.keyword)),
}));
}, [data?.trendingKeywords, isAuthenticated, user]);
}, [data?.trendingTags, isAuthenticated, user?.tags]);

const topPostsWithInterest = useMemo(() => {
const posts = data?.topPosts ?? [];
const userTags = user?.tags;
if (!isAuthenticated || !userTags?.length) return posts;
const userTagsNormalized = new Set(userTags.map(normalizeTag));
return posts.map((post) => ({
...post,
isMyInterest: post.tags.some((t) =>
userTagsNormalized.has(normalizeTag(t)),
),
}));
}, [data?.topPosts, isAuthenticated, user?.tags]);

const RANGE_LABEL: Record<TrendRange, string> = {
day: "일간",
week: "주간",
month: "월간",
daily: "일간",
weekly: "주간",
monthly: "월간",
};
const rangeLabel = RANGE_LABEL[range];
const rangeLabel = RANGE_LABEL[unit];

// isOpen이 false면 렌더하지 않음
// createPortal은 user interaction 이후에만 호출되므로 document.body 접근 안전
if (!isOpen) return null;

/** 수집 동향만 day 탭에서 비노출 — Top 5 동향은 항상 노출 */
const showCollectionSummary = range !== "day";
/** collectionSummary는 daily에서 항상 null, LLM 실패 시도 null — 둘 다 숨김 */
const showCollectionSummary =
unit !== "daily" && data?.collectionSummary != null;

return createPortal(
<div
Expand Down Expand Up @@ -165,36 +180,38 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) {
{/* 기간 탭 — 검색 중에는 숨김 */}
{!isSearching && (
<HomeRangeTabs
range={range}
onChange={handleRangeChange}
unit={unit}
onChange={handleUnitChange}
dateLabel={data?.dateLabel ?? ""}
className="mx-auto w-full max-w-5xl px-6 md:px-8"
/>
)}

{/* 스크롤 영역 — 전체 너비로 확장해 여백 포함 스크롤 가능 */}
<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="mx-auto max-w-5xl px-6 pb-6 pt-0 md:px-8 md:pb-8 md:pt-2">
{isSearching ? (
<HomeSearchResultsSection
results={searchResults}
activeItemId={activeItemId}
onToggle={handleToggleItem}
onClose={onClose}
isLoading={false}
isError={false}
/>
) : (
<div className="flex flex-col gap-10">
<HomeTopPostsSection
posts={data?.topPosts ?? []}
posts={topPostsWithInterest}
isLoading={isLoading}
rangeLabel={rangeLabel}
summary={data?.topPostsSummary}
onClose={onClose}
/>

{showCollectionSummary && (
<HomeCollectionSummarySection
summary={data?.collectionSummary ?? ""}
summary={data.collectionSummary!}
isLoading={isLoading}
rangeLabel={rangeLabel}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ interface HomeSearchResultAccordionItemProps {
item: SearchResultItem;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
}

export function HomeSearchResultAccordionItem({
item,
isOpen,
onToggle,
onClose,
}: HomeSearchResultAccordionItemProps) {
return (
<div className="border-b border-border">
Expand Down Expand Up @@ -64,7 +66,8 @@ export function HomeSearchResultAccordionItem({
))}
</div>
<Link
href={item.url}
href={`/home/${item.id}`}
onClick={onClose}
className="mt-1 w-fit text-xs font-medium text-primary hover:underline"
>
전체 글 보기 →
Expand Down
3 changes: 3 additions & 0 deletions components/features/home/search/HomeSearchResultsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface HomeSearchResultsSectionProps {
results: SearchResultItem[];
activeItemId: string | null;
onToggle: (id: string) => void;
onClose: () => void;
isLoading?: boolean;
isError?: boolean;
}
Expand All @@ -27,6 +28,7 @@ export function HomeSearchResultsSection({
results,
activeItemId,
onToggle,
onClose,
isLoading = false,
isError = false,
}: HomeSearchResultsSectionProps) {
Expand Down Expand Up @@ -69,6 +71,7 @@ export function HomeSearchResultsSection({
item={item}
isOpen={activeItemId === item.id}
onToggle={() => onToggle(item.id)}
onClose={onClose}
/>
))}
</div>
Expand Down
27 changes: 20 additions & 7 deletions components/features/home/search/HomeTopPostsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { Skeleton } from "@/components/ui/skeleton";
import type { TrendTopPost } from "@/lib/mock/home-search-trend";
import type { TrendTopPost } from "@/types/search";

function ThumbnailPlaceholder({ sourceName }: { sourceName: string }) {
return (
Expand All @@ -24,9 +25,19 @@ function ChangeRate({ rate }: { rate: number }) {
);
}

function TopPostCard({ post }: { post: TrendTopPost }) {
function TopPostCard({
post,
onClose,
}: {
post: TrendTopPost;
onClose: () => void;
}) {
return (
<div className="w-44 shrink-0 overflow-hidden rounded-md border border-border bg-card">
<Link
href={`/home/${post.id}`}
onClick={onClose}
className="w-44 shrink-0 overflow-hidden rounded-md border border-border bg-card block"
>
{/* 썸네일 영역 */}
<div className="relative aspect-video w-full overflow-hidden">
{post.thumbnailUrl ? (
Expand All @@ -51,7 +62,7 @@ function TopPostCard({ post }: { post: TrendTopPost }) {
{post.category}
</span>
<p className="line-clamp-2 text-xs font-semibold leading-snug text-foreground">
{post.title}
{post.translatedTitle || post.title}
</p>
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">
Expand All @@ -60,7 +71,7 @@ function TopPostCard({ post }: { post: TrendTopPost }) {
<ChangeRate rate={post.changeRate} />
</div>
</div>
</div>
</Link>
);
}

Expand All @@ -82,14 +93,16 @@ interface HomeTopPostsSectionProps {
posts: TrendTopPost[];
isLoading: boolean;
rangeLabel: string;
summary?: string;
summary?: string | null;
onClose: () => void;
}

export function HomeTopPostsSection({
posts,
isLoading,
rangeLabel,
summary,
onClose,
}: HomeTopPostsSectionProps) {
return (
<section>
Expand Down Expand Up @@ -119,7 +132,7 @@ export function HomeTopPostsSection({
? Array.from({ length: 5 }).map((_, i) => (
<TopPostCardSkeleton key={i} />
))
: posts.map((post) => <TopPostCard key={post.id} post={post} />)}
: posts.map((post) => <TopPostCard key={post.id} post={post} onClose={onClose} />)}
</div>
</div>
</section>
Expand Down
Loading
Loading