Skip to content

Commit 256ac6a

Browse files
authored
SCRUM-209-인기 카테고리 조회 API 연동 (#31)
* feat: 인기 키워드 조회 API 연동 * feat: 코드 리뷰 반영
1 parent 077a4c7 commit 256ac6a

7 files changed

Lines changed: 223 additions & 14 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import WordCloud, { Keyword } from "@/components/trending/word-cloud";
2+
import { getTrendKeywords } from "@/service/keyword";
3+
4+
export default async function TrendPage() {
5+
const { result } = await getTrendKeywords();
6+
7+
const keywords: Keyword[] = result.mostUsedKeywordList
8+
.sort((a, b) => b.usedCnt - a.usedCnt)
9+
.map((item, idx) => ({
10+
word: item.keywordName,
11+
rank: idx + 1,
12+
usedCnt: item.usedCnt,
13+
}));
14+
15+
return (
16+
<main className="max-w-with-header flex min-h-body flex-1 flex-col gap-2.5 px-10">
17+
<h1 className="text-3xl font-semibold text-white">트렌드 키워드</h1>
18+
<h2 className="mb-10 text-gray7">많이 검색되는 키워드를 살펴보세요.</h2>
19+
<WordCloud keywords={keywords} />
20+
</main>
21+
);
22+
}

apps/www/src/components/layout/header/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const Header = async () => {
1616
<p className="text-xl font-semibold">Nebula</p>
1717
</Link>
1818
<div className="flex items-center gap-6">
19-
<Link href="/history">History List</Link>
19+
<Link href="/trending">Trending</Link>
20+
<Link href="/history">History</Link>
2021
<LoginButton token={token?.value || ""} />
2122
</div>
2223
</header>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useRef, useState } from "react";
4+
import Link from "next/link";
5+
6+
export interface Keyword {
7+
word: string;
8+
rank: number;
9+
usedCnt: number;
10+
}
11+
12+
const minFont = 16;
13+
const maxFont = 48;
14+
const colors = ["#fff", "#A5B4FC", "#818CF8", "#6366F1", "#64748B"];
15+
16+
function getFontSize(rank: number) {
17+
return Math.max(maxFont - (rank - 1) * 6, minFont);
18+
}
19+
function getColor(rank: number) {
20+
return colors[rank - 1] || colors[colors.length - 1];
21+
}
22+
23+
interface PlacedKeyword {
24+
word: string;
25+
rank: number;
26+
usedCnt: number;
27+
left: number;
28+
top: number;
29+
fontSize: number;
30+
color: string;
31+
width: number;
32+
height: number;
33+
}
34+
35+
function measureText(
36+
text: string,
37+
fontSize: number,
38+
fontWeight = 700
39+
): { width: number; height: number } {
40+
if (typeof window === "undefined") return { width: 100, height: fontSize };
41+
const canvas = document.createElement("canvas");
42+
const ctx = canvas.getContext("2d");
43+
if (!ctx) return { width: 100, height: fontSize };
44+
ctx.font = `${fontWeight} ${fontSize}px Pretendard, sans-serif`;
45+
const metrics = ctx.measureText(text);
46+
return { width: metrics.width, height: fontSize };
47+
}
48+
49+
function isOverlap(a: PlacedKeyword, b: PlacedKeyword) {
50+
return !(
51+
a.left + a.width < b.left ||
52+
a.left > b.left + b.width ||
53+
a.top + a.height < b.top ||
54+
a.top > b.top + b.height
55+
);
56+
}
57+
58+
function placeKeywords(keywords: Keyword[], width: number, height: number): PlacedKeyword[] {
59+
const placed: PlacedKeyword[] = [];
60+
const maxTries = 100;
61+
for (const k of keywords) {
62+
const fontSize = getFontSize(k.rank);
63+
const color = getColor(k.rank);
64+
const { width: textWidth, height: textHeight } = measureText(k.word, fontSize);
65+
let left = 0,
66+
top = 0,
67+
tries = 0;
68+
let overlap = false;
69+
do {
70+
const maxLeft = Math.max(width - textWidth, 0);
71+
const maxTop = Math.max(height - textHeight, 0);
72+
left = Math.random() * maxLeft;
73+
top = Math.random() * maxTop;
74+
overlap = placed.some((p) =>
75+
isOverlap(
76+
{
77+
...p,
78+
left,
79+
top,
80+
width: textWidth,
81+
height: textHeight,
82+
word: k.word,
83+
rank: k.rank,
84+
fontSize,
85+
color,
86+
usedCnt: k.usedCnt,
87+
} as PlacedKeyword,
88+
p
89+
)
90+
);
91+
tries++;
92+
} while (overlap && tries < maxTries);
93+
placed.push({
94+
word: k.word,
95+
rank: k.rank,
96+
usedCnt: k.usedCnt,
97+
left,
98+
top,
99+
fontSize,
100+
color,
101+
width: textWidth,
102+
height: textHeight,
103+
});
104+
}
105+
return placed;
106+
}
107+
108+
export default function WordCloud({ keywords }: { keywords: Keyword[] }) {
109+
const [isClient, setIsClient] = useState(false);
110+
const [size, setSize] = useState({ width: 900, height: 420 });
111+
const containerRef = useRef<HTMLDivElement>(null);
112+
113+
useEffect(() => {
114+
setIsClient(true);
115+
if (!containerRef.current) return;
116+
const handleResize = () => {
117+
if (containerRef.current) {
118+
setSize({
119+
width: containerRef.current.offsetWidth,
120+
height: containerRef.current.offsetHeight,
121+
});
122+
}
123+
};
124+
handleResize();
125+
if (typeof window !== "undefined" && "ResizeObserver" in window) {
126+
const observer = new window.ResizeObserver(handleResize);
127+
observer.observe(containerRef.current);
128+
return () => observer.disconnect();
129+
}
130+
}, []);
131+
132+
const placedKeywords = useMemo(() => {
133+
if (!isClient) return [];
134+
return placeKeywords(keywords, size.width, size.height);
135+
}, [isClient, keywords, size]);
136+
137+
if (!isClient) return null;
138+
139+
return (
140+
<div
141+
ref={containerRef}
142+
className="relative mx-auto flex h-full w-full flex-row-reverse rounded-lg bg-gray7/30 px-20 py-16"
143+
>
144+
{placedKeywords.map((k) => (
145+
<Link
146+
key={k.word}
147+
target="_blank"
148+
href={`https://www.google.com/search?q=${k.word}`}
149+
className="absolute flex cursor-pointer select-none items-center gap-1 whitespace-nowrap font-bold transition-transform duration-150"
150+
style={{
151+
left: k.left,
152+
top: k.top,
153+
fontSize: k.fontSize,
154+
color: k.color,
155+
textShadow: "0 2px 8px rgba(0,0,0,0.15)",
156+
}}
157+
>
158+
{k.word}
159+
</Link>
160+
))}
161+
<ul className="flex flex-col flex-wrap text-white">
162+
{placedKeywords.slice(0, 10).map((k) => (
163+
<li
164+
key={`trend-keyword-${k.word}`}
165+
className="flex w-80 items-center justify-between gap-5 border-b border-b-gray7 px-4 py-2.5"
166+
>
167+
<Link
168+
target="_blank"
169+
href={`https://www.google.com/search?q=${k.word}`}
170+
className="flex gap-4 overflow-hidden text-sm"
171+
>
172+
<p className="text-sm">{k.rank}</p>
173+
<p className="truncate text-sm">{k.word}</p>
174+
</Link>
175+
<p className="text-xs">{k.usedCnt}</p>
176+
</li>
177+
))}
178+
</ul>
179+
</div>
180+
);
181+
}

apps/www/src/models/keyword.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { KeywordProps } from "@/types/keyword";
2+
3+
export interface TrendKeywordDTO {
4+
mostUsedKeywordList: KeywordProps[];
5+
}

apps/www/src/service/api.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,10 @@ const customFetch = async (url: string, method: Method, _body?: unknown, options
2222
headers,
2323
});
2424

25-
// if (res.status === 401 && !isRetry) {
26-
// const refreshResponse = await fetch(`${baseUrl}/auth/refresh`, {
27-
// method: "POST",
28-
// headers: {
29-
// "Content-Type": "application/json",
30-
// },
31-
// credentials: "include",
32-
// });
33-
// if (refreshResponse.ok) {
34-
// const res = await refreshResponse.json();
35-
// console.log("refresh response", res);
36-
// }
37-
// }
25+
if (res.status === 401 && typeof window !== "undefined") {
26+
window.location.replace("/login");
27+
return new Promise(() => {});
28+
}
3829

3930
if (!res.ok) {
4031
const errorText = await res.text();

apps/www/src/service/keyword.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { BaseResponseDTO } from "@/models";
2+
import { TrendKeywordDTO } from "@/models/keyword";
23

34
import api from "./api";
45

56
export const getKeywords = async (): Promise<BaseResponseDTO<string[]>> => {
67
return await api.get("/keywords");
78
};
9+
10+
export const getTrendKeywords = async (): Promise<BaseResponseDTO<TrendKeywordDTO>> => {
11+
return await api.get("/keywords/trending", { next: { revalidate: 60 } });
12+
};

apps/www/src/types/keyword.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface KeywordProps {
2+
keywordName: string;
3+
usedCnt: number;
4+
}

0 commit comments

Comments
 (0)