Skip to content

Commit 60c7133

Browse files
Merge pull request #65 from ShipFriend0516/feature/tags
2 parents a87455d + cfce5c6 commit 60c7133

8 files changed

Lines changed: 393 additions & 4 deletions

File tree

app/api/posts/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export async function GET(req: Request) {
1515
// 기존 파라미터
1616
const query = searchParams.get('query') || '';
1717
const seriesSlug = searchParams.get('series') || '';
18+
const tagParam = searchParams.get('tag') || '';
1819
const isCompact = searchParams.get('compact') === 'true';
1920
const isCanViewPrivate = searchParams.get('private') === 'true';
2021

@@ -59,6 +60,13 @@ export async function GET(req: Request) {
5960
} as QuerySelector<string>);
6061
}
6162

63+
// 태그 필터
64+
if (tagParam) {
65+
(searchConditions.$and as QuerySelector<string>[]).push({
66+
tags: tagParam,
67+
} as QuerySelector<string>);
68+
}
69+
6270
// 검색 조건을 만족하는 총 문서 수 계산
6371
const totalPosts = await Post.countDocuments(searchConditions);
6472

app/api/tags/route.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import dbConnect from '@/app/lib/dbConnect';
2+
import Post from '@/app/models/Post';
3+
4+
// GET /api/tags
5+
export async function GET() {
6+
try {
7+
await dbConnect();
8+
9+
const tagStats = await Post.aggregate([
10+
{ $match: { isPrivate: { $ne: true } } },
11+
{ $unwind: '$tags' },
12+
{ $group: { _id: '$tags', count: { $sum: 1 } } },
13+
{ $sort: { count: -1 } },
14+
{ $project: { tag: '$_id', count: 1, _id: 0 } },
15+
]);
16+
17+
return Response.json(tagStats, {
18+
status: 200,
19+
headers: {
20+
'Cache-Control': 'public, max-age=300',
21+
},
22+
});
23+
} catch (error) {
24+
console.error('Tags API error:', error);
25+
return Response.json(
26+
{ success: false, error: '태그 목록 불러오기 실패', detail: error },
27+
{ status: 500 }
28+
);
29+
}
30+
}

app/entities/common/Footer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ const Footer = () => {
7979
<div>
8080
<Link href={'/portfolio'}>Portfolio</Link>
8181
</div>
82+
<div>
83+
<Link href={'/tags'}>Tags</Link>
84+
</div>
8285
<div>
8386
<Link href={'/admin'}>Admin</Link>
8487
</div>

app/entities/post/list/SearchSection.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ interface SearchSectionProps {
1515
setQuery: (query: string) => void;
1616
resetSearchCondition: () => void;
1717
searchSeries: string;
18+
searchTag?: string;
1819
}
1920

2021
const SearchSection = ({
2122
query,
2223
setQuery,
2324
resetSearchCondition,
2425
searchSeries,
26+
searchTag,
2527
}: SearchSectionProps) => {
2628
const [searchOpen, setSearchOpen] = useState(false);
2729
const [seriesOpen, setSeriesOpen] = useState(false);
@@ -76,7 +78,7 @@ const SearchSection = ({
7678

7779
{/* 검색 버튼 및 검색창 */}
7880
<div className={'flex items-center'}>
79-
{(query || searchSeries) && (
81+
{(query || searchSeries || searchTag) && (
8082
<div
8183
className={
8284
'bg-neutral-600 rounded-lg px-2 text-sm py-0.5 text-white'
@@ -87,12 +89,17 @@ const SearchSection = ({
8789
<b>{searchSeries} </b> 시리즈에서{' '}
8890
</span>
8991
)}
92+
{searchTag && (
93+
<span>
94+
<b>#{searchTag}</b> 태그로{' '}
95+
</span>
96+
)}
9097
<span>
9198
<b>{query ? query : '전체'}</b>로 검색 중...
9299
</span>
93100
</div>
94101
)}
95-
{(query || searchSeries) && (
102+
{(query || searchSeries || searchTag) && (
96103
<button
97104
onClick={resetSearchCondition}
98105
className="p-2 hover:bg-gray-100 hover:text-black rounded-full transition-colors"

app/entities/tag/TagCloud.tsx

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
'use client';
2+
3+
import { motion } from 'motion/react';
4+
import Link from 'next/link';
5+
import { useEffect, useMemo, useState } from 'react';
6+
import { TagData, TagWithPosition } from '@/app/types/Tag';
7+
8+
interface TagCloudProps {
9+
tags: TagData[];
10+
}
11+
12+
interface Particle {
13+
id: number;
14+
x: number;
15+
y: number;
16+
z: number;
17+
size: number;
18+
speedX: number;
19+
speedY: number;
20+
speedZ: number;
21+
opacity: number;
22+
}
23+
24+
const TagCloud = ({ tags }: TagCloudProps) => {
25+
const [hoveredTag, setHoveredTag] = useState<string | null>(null);
26+
const [particles, setParticles] = useState<Particle[]>([]);
27+
28+
// 마법 가루 파티클 생성
29+
useEffect(() => {
30+
const particleCount = 120;
31+
const radius = 250;
32+
33+
const newParticles: Particle[] = Array.from(
34+
{ length: particleCount },
35+
(_, i) => {
36+
// 랜덤 구형 좌표
37+
const theta = Math.random() * Math.PI * 2;
38+
const phi = Math.acos(2 * Math.random() - 1);
39+
const r = radius * (0.6 + Math.random() * 0.5);
40+
41+
return {
42+
id: i,
43+
x: r * Math.sin(phi) * Math.cos(theta),
44+
y: r * Math.sin(phi) * Math.sin(theta),
45+
z: r * Math.cos(phi),
46+
size: 2 + Math.random() * 3,
47+
speedX: (Math.random() - 0.5) * 0.5,
48+
speedY: (Math.random() - 0.5) * 0.5,
49+
speedZ: (Math.random() - 0.5) * 0.5,
50+
opacity: 0.3 + Math.random() * 0.4,
51+
};
52+
}
53+
);
54+
55+
setParticles(newParticles);
56+
57+
// 파티클 애니메이션
58+
const interval = setInterval(() => {
59+
setParticles((prev) =>
60+
prev.map((p) => {
61+
let newX = p.x + p.speedX;
62+
let newY = p.y + p.speedY;
63+
let newZ = p.z + p.speedZ;
64+
65+
// 경계 체크 및 반사
66+
const maxRadius = radius * 1.1;
67+
const distance = Math.sqrt(newX ** 2 + newY ** 2 + newZ ** 2);
68+
if (distance > maxRadius) {
69+
newX = p.x - p.speedX;
70+
newY = p.y - p.speedY;
71+
newZ = p.z - p.speedZ;
72+
}
73+
74+
return {
75+
...p,
76+
x: newX,
77+
y: newY,
78+
z: newZ,
79+
};
80+
})
81+
);
82+
}, 50);
83+
84+
return () => clearInterval(interval);
85+
}, []);
86+
87+
// Fibonacci Sphere 알고리즘으로 3D 구형 좌표 계산
88+
const tagsWithPositions = useMemo(() => {
89+
const total = tags.length;
90+
const goldenRatio = (1 + Math.sqrt(5)) / 2;
91+
92+
return tags.map((tag, index) => {
93+
const phi = Math.acos(1 - (2 * (index + 0.5)) / total);
94+
const theta = 2 * Math.PI * index * goldenRatio;
95+
96+
// 반응형 반지름
97+
const radius =
98+
typeof window !== 'undefined'
99+
? window.innerWidth < 768
100+
? 150 // 모바일
101+
: window.innerWidth < 1024
102+
? 200 // 태블릿
103+
: 250 // 데스크톱
104+
: 250;
105+
106+
const x = radius * Math.sin(phi) * Math.cos(theta);
107+
const y = radius * Math.sin(phi) * Math.sin(theta);
108+
const z = radius * Math.cos(phi);
109+
110+
return {
111+
...tag,
112+
position: { x, y, z },
113+
};
114+
});
115+
}, [tags]);
116+
117+
// Z축 기반 스타일 계산
118+
const getTagStyle = (tagWithPos: TagWithPosition, isHovered: boolean) => {
119+
const { position, count } = tagWithPos;
120+
const { x, y, z } = position;
121+
122+
// z값 정규화 (-radius ~ radius → 0 ~ 1)
123+
const radius = 250;
124+
const normalized = (z + radius) / (radius * 2);
125+
126+
// 태그 빈도에 따른 기본 크기 조정 (추가)
127+
const countFactor = Math.log(count + 1) / Math.log(tags[0].count + 1);
128+
const baseSize = 0.7 + countFactor * 0.6; // 0.7 ~ 1.3
129+
130+
const scale = (0.5 + normalized * 1.5) * baseSize;
131+
const opacity = 0.3 + normalized * 0.7;
132+
const blur = (1 - normalized) * 2;
133+
const fontSize = (12 + normalized * 24) * baseSize * 0.7;
134+
135+
return {
136+
x,
137+
y,
138+
scale: isHovered ? scale * 1.3 : scale,
139+
opacity,
140+
filter: `blur(${blur}px)`,
141+
fontSize: `${fontSize}px`,
142+
zIndex: Math.round(normalized * 100),
143+
};
144+
};
145+
146+
// 주변 태그 밀어내기 효과 계산
147+
const calculatePushEffect = (
148+
targetPos: TagWithPosition,
149+
hoveredPos: TagWithPosition
150+
) => {
151+
if (!hoveredTag || hoveredTag !== hoveredPos.tag) {
152+
return { x: 0, y: 0 };
153+
}
154+
155+
const dx = targetPos.position.x - hoveredPos.position.x;
156+
const dy = targetPos.position.y - hoveredPos.position.y;
157+
const distance = Math.sqrt(dx * dx + dy * dy);
158+
159+
const threshold = 100;
160+
if (distance > threshold || distance === 0) return { x: 0, y: 0 };
161+
162+
const force = (1 - distance / threshold) * 30;
163+
const angle = Math.atan2(dy, dx);
164+
165+
return {
166+
x: Math.cos(angle) * force,
167+
y: Math.sin(angle) * force,
168+
};
169+
};
170+
171+
const hoveredTagData = tagsWithPositions.find((t) => t.tag === hoveredTag);
172+
173+
return (
174+
<div className="relative w-full h-[600px] flex items-center justify-center overflow-hidden">
175+
{/* 마법 가루 파티클 */}
176+
{particles.map((particle) => {
177+
const radius = 250;
178+
const normalized = (particle.z + radius) / (radius * 2);
179+
const particleScale = 0.3 + normalized * 0.7;
180+
const particleOpacity = particle.opacity * normalized;
181+
182+
return (
183+
<motion.div
184+
key={`particle-${particle.id}`}
185+
className="absolute rounded-full pointer-events-none"
186+
style={{
187+
width: `${particle.size}px`,
188+
height: `${particle.size}px`,
189+
background: `radial-gradient(circle, rgba(0, 223, 129, ${particleOpacity}), rgba(44, 194, 149, 0))`,
190+
boxShadow: `0 0 ${particle.size * 2}px rgba(0, 223, 129, ${particleOpacity * 0.5})`,
191+
zIndex: Math.round(normalized * 50),
192+
}}
193+
animate={{
194+
x: particle.x,
195+
y: particle.y,
196+
scale: particleScale,
197+
opacity: particleOpacity,
198+
}}
199+
transition={{
200+
duration: 0.05,
201+
ease: 'linear',
202+
}}
203+
/>
204+
);
205+
})}
206+
207+
{/* 태그들 */}
208+
{tagsWithPositions.map((tagWithPos) => {
209+
const style = getTagStyle(tagWithPos, hoveredTag === tagWithPos.tag);
210+
const pushEffect = hoveredTagData
211+
? calculatePushEffect(tagWithPos, hoveredTagData)
212+
: { x: 0, y: 0 };
213+
214+
return (
215+
<motion.div
216+
key={tagWithPos.tag}
217+
className="absolute"
218+
initial={{ opacity: 0, scale: 0 }}
219+
animate={{
220+
x: style.x + pushEffect.x,
221+
y: style.y + pushEffect.y,
222+
scale: style.scale,
223+
opacity: style.opacity,
224+
}}
225+
transition={{
226+
type: 'spring',
227+
stiffness: 150,
228+
damping: 20,
229+
opacity: { duration: 0.5 },
230+
}}
231+
style={{
232+
filter: style.filter,
233+
fontSize: style.fontSize,
234+
zIndex: style.zIndex,
235+
}}
236+
onHoverStart={() => setHoveredTag(tagWithPos.tag)}
237+
onHoverEnd={() => setHoveredTag(null)}
238+
>
239+
<Link
240+
href={`/posts?page=1&tag=${encodeURIComponent(tagWithPos.tag)}`}
241+
className="font-bold text-primary-bangladesh hover:text-primary-mountain
242+
dark:text-primary-caribbean dark:hover:text-primary-mountain
243+
transition-colors duration-300 cursor-pointer
244+
whitespace-nowrap select-none"
245+
aria-label={`${tagWithPos.tag} 태그 (${tagWithPos.count}개 글)`}
246+
>
247+
#{tagWithPos.tag}
248+
</Link>
249+
</motion.div>
250+
);
251+
})}
252+
</div>
253+
);
254+
};
255+
256+
export default TagCloud;

0 commit comments

Comments
 (0)