diff --git a/src/app/(root)/boards/BoardsPage.module.css b/src/app/(root)/boards/BoardsPage.module.css new file mode 100644 index 0000000..aa08000 --- /dev/null +++ b/src/app/(root)/boards/BoardsPage.module.css @@ -0,0 +1,254 @@ +.container { + position: relative; + max-width: 1200px; + margin: 0 auto; + padding: 40px 24px 80px; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 40px; +} + +.pageTitle { + margin: 0; + font-size: 24px; + font-weight: 700; + color: var(--color-text-primary); +} + +.searchWrapper { + display: flex; + align-items: center; + gap: 12px; + width: 400px; + height: 48px; + padding: 0 20px; + border: 2px solid var(--color-brand-primary); + border-radius: 24px; + background: var(--color-background-inverse); +} + +.searchIcon { + flex-shrink: 0; + cursor: pointer; +} + +.searchInput { + flex: 1; + border: none; + outline: none; + background: transparent; + font-family: var(--font-pretendard), 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 400; + color: var(--color-text-tertiary); +} + +.searchInput::placeholder { + color: var(--color-text-default); +} + +/* Section Common */ +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.sectionTitle { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--color-text-primary); +} + +.moreButton { + display: flex; + align-items: center; + gap: 4px; + padding: 0; + border: none; + background: none; + font-family: var(--font-pretendard), 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 400; + color: var(--color-text-default); + cursor: pointer; +} + +.moreButton:hover { + color: var(--color-text-secondary); +} + +/* Best Section */ +.bestSection { + margin-bottom: 48px; + background: var(--color-background-secondary); + border-radius: 16px; + padding: 24px; +} + +.carouselContainer { + overflow: hidden; +} + +.carouselTrack { + display: flex; + gap: 16px; + align-items: stretch; +} + +.bestCardWrapper { + flex: 1; + min-width: 0; +} + +.carouselControls { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; +} + +.dots { + display: flex; + align-items: center; + gap: 8px; + margin: 0 auto; +} + +.dot { + width: 8px; + height: 8px; + border: none; + border-radius: 4px; + padding: 0; + background: #cbd5e1; + cursor: pointer; + transition: + width 0.3s ease, + background 0.2s ease; +} + +.dotActive { + width: 20px; + background: var(--color-interaction-inactive); +} + +.arrows { + display: flex; + align-items: center; + gap: 8px; +} + +.arrowButton { + cursor: pointer; +} + +/* All Section */ +.allSection { + margin-bottom: 40px; +} + +.articleGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + align-items: stretch; +} + +/* Dropdown Override */ +.dropdownMenu { + min-height: auto; +} + +/* Floating Button - 카드 영역 오른쪽에 떠있음 */ +.floatingButtonWrapper { + position: fixed; + bottom: 120px; + left: calc(72px + (100vw - 72px) / 2 + 600px + 24px); +} + +/* Tablet */ +@media (max-width: 1199px) { + .container { + padding: 32px 24px 80px; + } + + .searchWrapper { + width: 320px; + } + + .bestSection { + margin-left: -24px; + margin-right: -24px; + border-radius: 0; + } + + .bestCardWrapper { + flex: 0 0 100%; + } + + .articleGrid { + grid-template-columns: 1fr; + } + + .floatingButtonWrapper { + left: auto; + bottom: 32px; + right: 32px; + } +} + +/* Mobile */ +@media (max-width: 767px) { + .container { + padding: 24px 16px 80px; + } + + .bestSection { + margin-left: -16px; + margin-right: -16px; + } + + .header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .searchWrapper { + width: 100%; + } + + .pageTitle { + font-size: 20px; + } + + .sectionTitle { + font-size: 18px; + } + + .carouselTrack { + gap: 12px; + } + + .bestCardWrapper { + flex: 0 0 100%; + } + + .articleGrid { + grid-template-columns: 1fr; + } + + .floatingButtonWrapper { + left: auto; + bottom: 24px; + right: 24px; + } +} diff --git a/src/app/(root)/boards/BoardsPage.tsx b/src/app/(root)/boards/BoardsPage.tsx new file mode 100644 index 0000000..fd882d6 --- /dev/null +++ b/src/app/(root)/boards/BoardsPage.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { motion, AnimatePresence } from 'framer-motion'; +import ArticleCard from '@/components/Card/ArticleCard/ArticleCard'; +import FloatingButton from '@/components/Button/domain/FloatingButton/FloatingButton'; +import Dropdown from '@/components/dropdown/Dropdown'; +import useBreakpoint from './useBreakpoint'; +import searchIcon from '@/assets/icons/search/searchLarge.svg'; +import arrowLeftIcon from '@/assets/buttons/arrow/leftArrowButton.svg'; +import arrowRightIcon from '@/assets/buttons/arrow/rightArrowButton.svg'; +import arrowRightLarge from '@/assets/icons/arrow/arrowRightLarge.svg'; +import styles from './BoardsPage.module.css'; + +interface Article { + id: number; + title: string; + content?: string; + writer: { nickname: string; id: number }; + createdAt: string; + likeCount: number; + image?: string | null; +} + +interface BoardsPageProps { + bestArticles?: Article[]; + articles?: Article[]; + hasMore?: boolean; + onLoadMore?: () => void; + onSearch?: (keyword: string) => void; + onSort?: (orderBy: 'recent' | 'like') => void; +} + +const VISIBLE_COUNT_MAP = { desktop: 3, tablet: 2, mobile: 1 } as const; + +const SORT_OPTIONS = [ + { value: 'latest', label: '최신순' }, + { value: 'likes', label: '좋아요 많은순' }, +]; + +const SORT_ORDER_MAP: Record = { + latest: 'recent', + likes: 'like', +}; + +export default function BoardsPage({ + bestArticles = [], + articles = [], + hasMore = false, + onLoadMore, + onSearch, + onSort, +}: BoardsPageProps) { + const router = useRouter(); + const breakpoint = useBreakpoint(); + const visibleCount = VISIBLE_COUNT_MAP[breakpoint]; + + const [currentPage, setCurrentPage] = useState(0); + const [direction, setDirection] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [, setSortBy] = useState('latest'); + const sentinelRef = useRef(null); + + const handleIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + if (entries[0].isIntersecting && hasMore) { + onLoadMore?.(); + } + }, + [hasMore, onLoadMore], + ); + + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver(handleIntersect, { rootMargin: '200px' }); + observer.observe(el); + return () => observer.disconnect(); + }, [handleIntersect]); + + const totalPages = Math.ceil(bestArticles.length / visibleCount); + const safePage = Math.min(currentPage, Math.max(0, totalPages - 1)); + + if (safePage !== currentPage) { + setCurrentPage(safePage); + } + + const visibleBestArticles = bestArticles.slice( + safePage * visibleCount, + safePage * visibleCount + visibleCount, + ); + + const handleSearchSubmit = () => { + onSearch?.(searchQuery); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearchSubmit(); + } + }; + + const handleSortChange = (value: string) => { + setSortBy(value); + onSort?.(SORT_ORDER_MAP[value] || 'recent'); + }; + + const handlePrev = () => { + setDirection(-1); + setCurrentPage((prev) => Math.max(0, prev - 1)); + }; + const handleNext = () => { + setDirection(1); + setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1)); + }; + + return ( +
+
+

자유게시판

+
+ 검색 + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ +
+
+

베스트 게시글

+ +
+ +
+ + ({ x: d > 0 ? 40 : -40, opacity: 0 }), + center: { x: 0, opacity: 1 }, + exit: (d: number) => ({ x: d > 0 ? -40 : 40, opacity: 0 }), + }} + initial="enter" + animate="center" + exit="exit" + transition={{ duration: 0.25, ease: 'easeInOut' }} + className={styles.carouselTrack} + > + {visibleBestArticles.map((article) => ( +
+ router.push(`/boards/${article.id}`)} + /> +
+ ))} +
+
+
+ +
+
+ {Array.from({ length: totalPages }).map((_, i) => ( +
+
+ 이전 + 다음 +
+
+
+ +
+
+

전체

+ +
+ +
+ {articles.map((article) => ( + router.push(`/boards/${article.id}`)} + /> + ))} +
+
+
+ +
+ router.push('/boards/write')} /> +
+
+ ); +} diff --git a/src/app/(root)/boards/[articleId]/ArticleDetailPage.module.css b/src/app/(root)/boards/[articleId]/ArticleDetailPage.module.css new file mode 100644 index 0000000..bafa67f --- /dev/null +++ b/src/app/(root)/boards/[articleId]/ArticleDetailPage.module.css @@ -0,0 +1,338 @@ +.page { + min-height: 100vh; + background: var(--color-background-secondary); + padding: 40px 24px 80px; +} + +.layout { + display: flex; + gap: 24px; + max-width: 1200px; + margin: 0 auto; + align-items: flex-start; +} + +/* Card */ +.card { + flex: 1; + min-width: 0; + background: var(--color-background-primary); + border-radius: 16px; + padding: 32px; +} + +/* Header */ +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.title { + margin: 0; + font-size: 24px; + font-weight: 700; + line-height: 1.4; + color: var(--color-text-primary); +} + +.kebabWrapper { + position: relative; + flex-shrink: 0; +} + +.kebabButton { + padding: 0; + border: none; + background: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.kebabMenu { + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--color-background-primary); + border: 1px solid var(--color-background-tertiary); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + z-index: 10; + min-width: 120px; + overflow: hidden; +} + +.kebabMenuItem { + display: block; + width: 100%; + padding: 12px 16px; + border: none; + background: none; + text-align: left; + font-family: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; + color: var(--color-text-primary); +} + +.kebabMenuItem:hover { + background: var(--color-background-secondary); +} + +.kebabMenuDanger { + color: var(--color-status-danger); +} + +/* Author Row */ +.authorRow { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; +} + +.nickname { + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); +} + +.divider { + font-size: 14px; + color: var(--color-text-disabled); +} + +.date { + font-size: 14px; + color: var(--color-text-disabled); +} + +/* Separator */ +.separator { + border: none; + border-top: 1px solid var(--color-background-tertiary); + margin: 24px 0; +} + +/* Body */ +.body { + margin-bottom: 40px; +} + +.text { + margin: 0; + font-size: 16px; + line-height: 1.8; + color: var(--color-text-primary); + white-space: pre-wrap; +} + +.imageWrapper { + margin-top: 24px; + width: 200px; + height: 200px; + border-radius: 12px; + overflow: hidden; +} + +.articleImage { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Like Button - inline (hidden on desktop, shown on tablet/mobile) */ +.inlineLike { + display: none; + justify-content: flex-end; + margin-top: 24px; +} + +.likeButton { + display: flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: none; + cursor: pointer; + font-size: 16px; + font-weight: 500; + color: var(--color-text-disabled); +} + +/* Like Button - floating sidebar (hidden on tablet/mobile) */ +.floatingWrapper { + position: sticky; + top: 120px; + flex-shrink: 0; +} + +/* Comment Section */ +.commentSection { + padding-top: 24px; +} + +.commentTitle { + margin: 0 0 16px; + font-size: 18px; + font-weight: 700; + color: var(--color-text-primary); +} + +.commentCount { + color: var(--color-brand-primary); +} + +.commentList { + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.commentItem { + position: relative; +} + +.emptyComment { + margin: 40px 0; + text-align: center; + font-size: 14px; + color: var(--color-text-disabled); +} + +/* Comment Edit Form */ +.commentEditForm { + display: flex; + gap: 12px; + padding-top: 16px; + position: relative; +} + +.commentEditForm::before { + content: ''; + position: absolute; + top: 0; + left: 16px; + right: 0; + border-top: 1px solid var(--color-background-tertiary); +} + +.commentEditBody { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.commentEditTextarea { + width: 100%; + min-height: 80px; + padding: 12px; + border: 1px solid var(--color-background-tertiary); + border-radius: 8px; + font-family: inherit; + font-size: 14px; + color: var(--color-text-primary); + resize: vertical; + outline: none; +} + +.commentEditTextarea:focus { + border-color: var(--color-brand-primary); +} + +.commentEditActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.commentEditCancel { + padding: 8px 16px; + border: 1px solid var(--color-background-tertiary); + border-radius: 8px; + background: none; + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; +} + +.commentEditSave { + padding: 8px 16px; + border: none; + border-radius: 8px; + background: var(--color-brand-primary); + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: #fff; + cursor: pointer; +} + +.commentEditSave:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Toast */ +.toastWrapper { + position: fixed; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + z-index: 100; +} + +/* Tablet */ +@media (max-width: 1199px) { + .page { + padding: 32px 24px 80px; + } + + .inlineLike { + display: flex; + } + + .floatingWrapper { + display: none; + } +} + +/* Mobile */ +@media (max-width: 767px) { + .page { + padding: 16px; + } + + .card { + padding: 24px 16px; + border-radius: 12px; + } + + .title { + font-size: 18px; + } + + .text { + font-size: 14px; + } + + .imageWrapper { + width: 160px; + height: 160px; + } + + .commentTitle { + font-size: 16px; + } +} diff --git a/src/app/(root)/boards/[articleId]/ArticleDetailPage.tsx b/src/app/(root)/boards/[articleId]/ArticleDetailPage.tsx new file mode 100644 index 0000000..b164576 --- /dev/null +++ b/src/app/(root)/boards/[articleId]/ArticleDetailPage.tsx @@ -0,0 +1,390 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import CommentInput from '@/components/input/CommentInput'; +import CommentCard from '@/components/comment/CommentCard'; +import FloatingLikeButton from '@/components/Button/domain/FloatingButton/FloatingLikeButton'; +import Toast from '@/components/toast/Toast'; +import WarningModal from '@/components/Modal/domain/components/WarningModal/WarningModal'; +import { useDeleteArticle, useLikeArticle } from '../hooks/useArticles'; +import { useCreateComment, useUpdateComment, useDeleteComment } from '../hooks/useComments'; +import type { ArticleDetail } from '../apis/types'; +import type { Comment } from '../apis/comments'; +import kebabIcon from '@/assets/icons/kebab/kebabLarge.svg'; +import emptyHeartIcon from '@/assets/icons/heart/emptyHeartLarge.svg'; +import fullHeartIcon from '@/assets/icons/heart/fullHeartLarge.svg'; +import humanBig from '@/assets/buttons/human/humanBig.svg'; +import styles from './ArticleDetailPage.module.css'; + +function renderAvatar(image: string | null, size = 32) { + if (image) { + return ( +
+ +
+ ); + } + return ; +} + +interface ArticleDetailPageProps { + article: ArticleDetail; + comments?: Comment[]; + currentUserImage?: string | null; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${year}. ${month}. ${day}`; +} + +export default function ArticleDetailPage({ + article, + comments = [], + currentUserImage, +}: ArticleDetailPageProps) { + const router = useRouter(); + const [commentInput, setCommentInput] = useState(''); + + // Mutations + const deleteArticleMutation = useDeleteArticle(); + const likeMutation = useLikeArticle(); + const createCommentMutation = useCreateComment(article.id); + const updateCommentMutation = useUpdateComment(article.id); + const deleteCommentMutation = useDeleteComment(article.id); + + // Article kebab menu + const [showArticleMenu, setShowArticleMenu] = useState(false); + const articleMenuRef = useRef(null); + + // Comment kebab menus + const [activeCommentMenu, setActiveCommentMenu] = useState(null); + const commentMenuRef = useRef(null); + + // Comment editing + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingContent, setEditingContent] = useState(''); + + // Modal & Toast + const [deleteModal, setDeleteModal] = useState<{ + type: 'article' | 'comment'; + commentId?: number; + } | null>(null); + const [toastMessage, setToastMessage] = useState(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (articleMenuRef.current && !articleMenuRef.current.contains(e.target as Node)) { + setShowArticleMenu(false); + } + if (commentMenuRef.current && !commentMenuRef.current.contains(e.target as Node)) { + setActiveCommentMenu(null); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const showToast = (message: string) => { + setToastMessage(null); + setTimeout(() => setToastMessage(message), 0); + }; + + const handleCommentSubmit = () => { + if (!commentInput.trim()) return; + createCommentMutation.mutate(commentInput.trim(), { + onSuccess: () => setCommentInput(''), + onError: () => showToast('댓글 작성에 실패했습니다.'), + }); + }; + + const handleLikeToggle = () => { + likeMutation.mutate({ articleId: article.id, isLiked: article.isLiked }); + }; + + const handleArticleDelete = () => { + setDeleteModal({ type: 'article' }); + }; + + const handleDeleteConfirm = () => { + if (!deleteModal) return; + + if (deleteModal.type === 'article') { + deleteArticleMutation.mutate(article.id, { + onSuccess: () => { + setDeleteModal(null); + router.push('/boards'); + }, + onError: () => { + setDeleteModal(null); + showToast('게시글 삭제에 실패했습니다.'); + }, + }); + } else if (deleteModal.type === 'comment' && deleteModal.commentId) { + deleteCommentMutation.mutate(deleteModal.commentId, { + onSuccess: () => setDeleteModal(null), + onError: () => { + setDeleteModal(null); + showToast('댓글 삭제에 실패했습니다.'); + }, + }); + } + setActiveCommentMenu(null); + }; + + const handleCommentEdit = (comment: Comment) => { + setEditingCommentId(comment.id); + setEditingContent(comment.content); + setActiveCommentMenu(null); + }; + + const handleCommentEditSubmit = () => { + if (!editingCommentId || !editingContent.trim()) return; + updateCommentMutation.mutate( + { commentId: editingCommentId, content: editingContent.trim() }, + { + onSuccess: () => { + setEditingCommentId(null); + setEditingContent(''); + }, + onError: () => showToast('댓글 수정에 실패했습니다.'), + }, + ); + }; + + const handleCommentDelete = (commentId: number) => { + setDeleteModal({ type: 'comment', commentId }); + }; + + return ( +
+
+
+
+

{article.title}

+
+ + {showArticleMenu && ( +
+ + +
+ )} +
+
+ +
+ {renderAvatar(article.writer.image)} + {article.writer.nickname} + | + +
+ +
+ +
+ {article.content &&

{article.content}

} + {article.image && ( +
+ +
+ )} +
+ +
+
+ +
+

+ 댓글 {comments.length} +

+ setCommentInput(e.target.value)} + onSubmit={handleCommentSubmit} + profileImage={renderAvatar(currentUserImage ?? null)} + /> + + {comments.length > 0 ? ( +
+ {comments.map((comment) => ( +
+ {editingCommentId === comment.id ? ( +
+ {renderAvatar(comment.writer.image)} +
+