From 9f2cc6de024452e6eccc88690c5b4bada147a224 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Wed, 3 Dec 2025 17:54:31 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=83=9C=EA=B7=B8=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=98=EB=8A=94=20api=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/posts/route.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index eb6c9a0..fd92f21 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -15,6 +15,7 @@ export async function GET(req: Request) { // 기존 파라미터 const query = searchParams.get('query') || ''; const seriesSlug = searchParams.get('series') || ''; + const tagParam = searchParams.get('tag') || ''; const isCompact = searchParams.get('compact') === 'true'; const isCanViewPrivate = searchParams.get('private') === 'true'; @@ -59,6 +60,13 @@ export async function GET(req: Request) { } as QuerySelector); } + // 태그 필터 + if (tagParam) { + (searchConditions.$and as QuerySelector[]).push({ + tags: tagParam, + } as QuerySelector); + } + // 검색 조건을 만족하는 총 문서 수 계산 const totalPosts = await Post.countDocuments(searchConditions); From f9562a854838a9b9932ceb2328dc4cf6c33773ea Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Wed, 3 Dec 2025 17:55:17 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20tag=20=EC=A7=91=EA=B3=84=ED=95=98?= =?UTF-8?q?=EB=8A=94=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/tags/route.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/api/tags/route.ts diff --git a/app/api/tags/route.ts b/app/api/tags/route.ts new file mode 100644 index 0000000..bf87cea --- /dev/null +++ b/app/api/tags/route.ts @@ -0,0 +1,30 @@ +import dbConnect from '@/app/lib/dbConnect'; +import Post from '@/app/models/Post'; + +// GET /api/tags +export async function GET() { + try { + await dbConnect(); + + const tagStats = await Post.aggregate([ + { $match: { isPrivate: { $ne: true } } }, + { $unwind: '$tags' }, + { $group: { _id: '$tags', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + { $project: { tag: '$_id', count: 1, _id: 0 } }, + ]); + + return Response.json(tagStats, { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=300', + }, + }); + } catch (error) { + console.error('Tags API error:', error); + return Response.json( + { success: false, error: '태그 목록 불러오기 실패', detail: error }, + { status: 500 } + ); + } +} From 804f2554c28a88603521fc8354e09fe55ae30841 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Wed, 3 Dec 2025 17:55:30 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20tag=20=ED=81=B4=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/tag/TagCloud.tsx | 256 ++++++++++++++++++++++++++++++++++ app/tags/page.tsx | 67 +++++++++ app/types/Tag.ts | 14 ++ 3 files changed, 337 insertions(+) create mode 100644 app/entities/tag/TagCloud.tsx create mode 100644 app/tags/page.tsx create mode 100644 app/types/Tag.ts diff --git a/app/entities/tag/TagCloud.tsx b/app/entities/tag/TagCloud.tsx new file mode 100644 index 0000000..f5f0a52 --- /dev/null +++ b/app/entities/tag/TagCloud.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { motion } from 'motion/react'; +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { TagData, TagWithPosition } from '@/app/types/Tag'; + +interface TagCloudProps { + tags: TagData[]; +} + +interface Particle { + id: number; + x: number; + y: number; + z: number; + size: number; + speedX: number; + speedY: number; + speedZ: number; + opacity: number; +} + +const TagCloud = ({ tags }: TagCloudProps) => { + const [hoveredTag, setHoveredTag] = useState(null); + const [particles, setParticles] = useState([]); + + // 마법 가루 파티클 생성 + useEffect(() => { + const particleCount = 120; + const radius = 250; + + const newParticles: Particle[] = Array.from( + { length: particleCount }, + (_, i) => { + // 랜덤 구형 좌표 + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const r = radius * (0.6 + Math.random() * 0.5); + + return { + id: i, + x: r * Math.sin(phi) * Math.cos(theta), + y: r * Math.sin(phi) * Math.sin(theta), + z: r * Math.cos(phi), + size: 2 + Math.random() * 3, + speedX: (Math.random() - 0.5) * 0.5, + speedY: (Math.random() - 0.5) * 0.5, + speedZ: (Math.random() - 0.5) * 0.5, + opacity: 0.3 + Math.random() * 0.4, + }; + } + ); + + setParticles(newParticles); + + // 파티클 애니메이션 + const interval = setInterval(() => { + setParticles((prev) => + prev.map((p) => { + let newX = p.x + p.speedX; + let newY = p.y + p.speedY; + let newZ = p.z + p.speedZ; + + // 경계 체크 및 반사 + const maxRadius = radius * 1.1; + const distance = Math.sqrt(newX ** 2 + newY ** 2 + newZ ** 2); + if (distance > maxRadius) { + newX = p.x - p.speedX; + newY = p.y - p.speedY; + newZ = p.z - p.speedZ; + } + + return { + ...p, + x: newX, + y: newY, + z: newZ, + }; + }) + ); + }, 50); + + return () => clearInterval(interval); + }, []); + + // Fibonacci Sphere 알고리즘으로 3D 구형 좌표 계산 + const tagsWithPositions = useMemo(() => { + const total = tags.length; + const goldenRatio = (1 + Math.sqrt(5)) / 2; + + return tags.map((tag, index) => { + const phi = Math.acos(1 - (2 * (index + 0.5)) / total); + const theta = 2 * Math.PI * index * goldenRatio; + + // 반응형 반지름 + const radius = + typeof window !== 'undefined' + ? window.innerWidth < 768 + ? 150 // 모바일 + : window.innerWidth < 1024 + ? 200 // 태블릿 + : 250 // 데스크톱 + : 250; + + const x = radius * Math.sin(phi) * Math.cos(theta); + const y = radius * Math.sin(phi) * Math.sin(theta); + const z = radius * Math.cos(phi); + + return { + ...tag, + position: { x, y, z }, + }; + }); + }, [tags]); + + // Z축 기반 스타일 계산 + const getTagStyle = (tagWithPos: TagWithPosition, isHovered: boolean) => { + const { position, count } = tagWithPos; + const { x, y, z } = position; + + // z값 정규화 (-radius ~ radius → 0 ~ 1) + const radius = 250; + const normalized = (z + radius) / (radius * 2); + + // 태그 빈도에 따른 기본 크기 조정 (추가) + const countFactor = Math.log(count + 1) / Math.log(tags[0].count + 1); + const baseSize = 0.7 + countFactor * 0.6; // 0.7 ~ 1.3 + + const scale = (0.5 + normalized * 1.5) * baseSize; + const opacity = 0.3 + normalized * 0.7; + const blur = (1 - normalized) * 2; + const fontSize = (12 + normalized * 24) * baseSize * 0.7; + + return { + x, + y, + scale: isHovered ? scale * 1.3 : scale, + opacity, + filter: `blur(${blur}px)`, + fontSize: `${fontSize}px`, + zIndex: Math.round(normalized * 100), + }; + }; + + // 주변 태그 밀어내기 효과 계산 + const calculatePushEffect = ( + targetPos: TagWithPosition, + hoveredPos: TagWithPosition + ) => { + if (!hoveredTag || hoveredTag !== hoveredPos.tag) { + return { x: 0, y: 0 }; + } + + const dx = targetPos.position.x - hoveredPos.position.x; + const dy = targetPos.position.y - hoveredPos.position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + const threshold = 100; + if (distance > threshold || distance === 0) return { x: 0, y: 0 }; + + const force = (1 - distance / threshold) * 30; + const angle = Math.atan2(dy, dx); + + return { + x: Math.cos(angle) * force, + y: Math.sin(angle) * force, + }; + }; + + const hoveredTagData = tagsWithPositions.find((t) => t.tag === hoveredTag); + + return ( +
+ {/* 마법 가루 파티클 */} + {particles.map((particle) => { + const radius = 250; + const normalized = (particle.z + radius) / (radius * 2); + const particleScale = 0.3 + normalized * 0.7; + const particleOpacity = particle.opacity * normalized; + + return ( + + ); + })} + + {/* 태그들 */} + {tagsWithPositions.map((tagWithPos) => { + const style = getTagStyle(tagWithPos, hoveredTag === tagWithPos.tag); + const pushEffect = hoveredTagData + ? calculatePushEffect(tagWithPos, hoveredTagData) + : { x: 0, y: 0 }; + + return ( + setHoveredTag(tagWithPos.tag)} + onHoverEnd={() => setHoveredTag(null)} + > + + #{tagWithPos.tag} + + + ); + })} +
+ ); +}; + +export default TagCloud; diff --git a/app/tags/page.tsx b/app/tags/page.tsx new file mode 100644 index 0000000..ce770e5 --- /dev/null +++ b/app/tags/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import SVGLoadingSpinner from '@/app/entities/common/Loading/SVGLoadingSpinner'; +import TagCloud from '@/app/entities/tag/TagCloud'; +import { TagData } from '@/app/types/Tag'; + +const TagsPage = () => { + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchTags = async () => { + try { + const response = await fetch('/api/tags'); + if (!response.ok) { + throw new Error('태그 목록을 불러오는데 실패했습니다'); + } + const data = await response.json(); + setTags(data); + } catch (err) { + setError(err instanceof Error ? err.message : '알 수 없는 오류'); + console.error('Failed to fetch tags:', err); + } finally { + setLoading(false); + } + }; + + fetchTags(); + }, []); + + return ( +
+

태그

+

+ 태그를 클릭하면 관련 글을 확인할 수 있습니다 +

+ +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+

⚠️ {error}

+ +
+ ) : tags.length === 0 ? ( +
+

아직 태그가 없습니다

+
+ ) : ( + + )} +
+
+ ); +}; + +export default TagsPage; diff --git a/app/types/Tag.ts b/app/types/Tag.ts new file mode 100644 index 0000000..5665886 --- /dev/null +++ b/app/types/Tag.ts @@ -0,0 +1,14 @@ +export interface TagData { + tag: string; + count: number; +} + +export interface Position3D { + x: number; + y: number; + z: number; +} + +export interface TagWithPosition extends TagData { + position: Position3D; +} From a353ddab2ba7ee42007ad03f6d71ce24bd474763 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Wed, 3 Dec 2025 17:56:02 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=ED=83=9C=EA=B7=B8=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B8=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20searchParams=EB=A1=9C=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/post/list/SearchSection.tsx | 11 +++++++++-- app/posts/page.tsx | 8 ++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/entities/post/list/SearchSection.tsx b/app/entities/post/list/SearchSection.tsx index adf972a..26329a5 100644 --- a/app/entities/post/list/SearchSection.tsx +++ b/app/entities/post/list/SearchSection.tsx @@ -15,6 +15,7 @@ interface SearchSectionProps { setQuery: (query: string) => void; resetSearchCondition: () => void; searchSeries: string; + searchTag?: string; } const SearchSection = ({ @@ -22,6 +23,7 @@ const SearchSection = ({ setQuery, resetSearchCondition, searchSeries, + searchTag, }: SearchSectionProps) => { const [searchOpen, setSearchOpen] = useState(false); const [seriesOpen, setSeriesOpen] = useState(false); @@ -76,7 +78,7 @@ const SearchSection = ({ {/* 검색 버튼 및 검색창 */}
- {(query || searchSeries) && ( + {(query || searchSeries || searchTag) && (
{searchSeries} 시리즈에서{' '} )} + {searchTag && ( + + #{searchTag} 태그로{' '} + + )} {query ? query : '전체'}로 검색 중...
)} - {(query || searchSeries) && ( + {(query || searchSeries || searchTag) && (