diff --git a/next.config.ts b/next.config.ts index d504471..ae7b4fb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,11 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { images: { remotePatterns: [ + { + protocol: 'https', + hostname: 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com', + pathname: '/Coworkers/**', + }, { protocol: 'https', hostname: '**.kakaocdn.net', diff --git a/src/app/(root)/boards/BoardsPage.module.css b/src/app/(root)/boards/BoardsPage.module.css index aa08000..7584bc9 100644 --- a/src/app/(root)/boards/BoardsPage.module.css +++ b/src/app/(root)/boards/BoardsPage.module.css @@ -147,9 +147,63 @@ } .arrowButton { + padding: 0; + border: none; + background: none; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; } +.arrowButton:disabled { + opacity: 0.4; + cursor: default; +} + +/* Loading Indicator */ +.sentinel { + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.loadingIndicator { + display: flex; + gap: 6px; + align-items: center; +} + +.loadingDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-interaction-inactive); + animation: dotPulse 1.2s ease-in-out infinite; +} + +.loadingDot:nth-child(2) { + animation-delay: 0.2s; +} + +.loadingDot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes dotPulse { + 0%, + 80%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 40% { + opacity: 1; + transform: scale(1); + } +} + /* All Section */ .allSection { margin-bottom: 40px; diff --git a/src/app/(root)/boards/BoardsPage.tsx b/src/app/(root)/boards/BoardsPage.tsx index fd882d6..3dc8453 100644 --- a/src/app/(root)/boards/BoardsPage.tsx +++ b/src/app/(root)/boards/BoardsPage.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, memo } from 'react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { motion, AnimatePresence } from 'framer-motion'; @@ -28,6 +28,7 @@ interface BoardsPageProps { bestArticles?: Article[]; articles?: Article[]; hasMore?: boolean; + isFetchingMore?: boolean; onLoadMore?: () => void; onSearch?: (keyword: string) => void; onSort?: (orderBy: 'recent' | 'like') => void; @@ -45,22 +46,139 @@ const SORT_ORDER_MAP: Record = { likes: 'like', }; +const ArticleSortHeader = memo(function ArticleSortHeader({ + onSortChange, +}: { + onSortChange: (value: string) => void; +}) { + return ( +
+

전체

+ +
+ ); +}); + +const BestSection = memo(function BestSection({ bestArticles }: { bestArticles: Article[] }) { + const breakpoint = useBreakpoint(); + const visibleCount = VISIBLE_COUNT_MAP[breakpoint]; + + const [currentPage, setCurrentPage] = useState(0); + const [direction, setDirection] = useState(0); + + 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 handlePrev = () => { + setDirection(-1); + setCurrentPage((prev) => Math.max(0, prev - 1)); + }; + const handleNext = () => { + setDirection(1); + setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1)); + }; + + return ( +
+
+

베스트 게시글

+ +
+ +
+ + ({ 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) => ( +
+ +
+ ))} +
+
+
+ +
+
+ {Array.from({ length: totalPages }).map((_, i) => ( +
+
+ + +
+
+
+ ); +}); + export default function BoardsPage({ bestArticles = [], articles = [], hasMore = false, + isFetchingMore = 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 searchRef = useRef(null); const sentinelRef = useRef(null); const handleIntersect = useCallback( @@ -80,21 +198,9 @@ export default function BoardsPage({ 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 handleSearchSubmit = useCallback(() => { + onSearch?.(searchRef.current?.value ?? ''); + }, [onSearch]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { @@ -102,19 +208,12 @@ export default function BoardsPage({ } }; - 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)); - }; + const handleSortChange = useCallback( + (value: string) => { + onSort?.(SORT_ORDER_MAP[value] || 'recent'); + }, + [onSort], + ); return (
@@ -130,114 +229,35 @@ export default function BoardsPage({ onClick={handleSearchSubmit} /> setSearchQuery(e.target.value)} + defaultValue="" 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}`)} - /> + ))}
-
+
+ {isFetchingMore && ( +
+ + + +
+ )} +
diff --git a/src/app/(root)/boards/[articleId]/ArticleDetailPage.module.css b/src/app/(root)/boards/[articleId]/ArticleDetailPage.module.css index bafa67f..abcddf5 100644 --- a/src/app/(root)/boards/[articleId]/ArticleDetailPage.module.css +++ b/src/app/(root)/boards/[articleId]/ArticleDetailPage.module.css @@ -284,15 +284,6 @@ cursor: not-allowed; } -/* Toast */ -.toastWrapper { - position: fixed; - bottom: 40px; - left: 50%; - transform: translateX(-50%); - z-index: 100; -} - /* Tablet */ @media (max-width: 1199px) { .page { diff --git a/src/app/(root)/boards/[articleId]/ArticleDetailPage.tsx b/src/app/(root)/boards/[articleId]/ArticleDetailPage.tsx index b164576..e588ff4 100644 --- a/src/app/(root)/boards/[articleId]/ArticleDetailPage.tsx +++ b/src/app/(root)/boards/[articleId]/ArticleDetailPage.tsx @@ -376,14 +376,12 @@ export default function ArticleDetailPage({ /> {toastMessage && ( -
- setToastMessage(null)} - /> -
+ setToastMessage(null)} + /> )}
); diff --git a/src/app/(root)/boards/[articleId]/edit/ArticleEditPage.tsx b/src/app/(root)/boards/[articleId]/edit/ArticleEditPage.tsx index ae9ac12..5f4fde5 100644 --- a/src/app/(root)/boards/[articleId]/edit/ArticleEditPage.tsx +++ b/src/app/(root)/boards/[articleId]/edit/ArticleEditPage.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { useRouter } from 'next/navigation'; import Input from '@/components/input/Input'; import TextArea from '@/components/input/TextArea'; @@ -23,12 +23,17 @@ interface ArticleData { function ArticleEditForm({ articleId, article }: { articleId: number; article: ArticleData }) { const router = useRouter(); const updateArticleMutation = useUpdateArticle(); - - const [title, setTitle] = useState(article.title); - const [content, setContent] = useState(article.content); + const titleRef = useRef(null); + const contentRef = useRef(null); + const [isValid, setIsValid] = useState(true); const [images, setImages] = useState(article.image ? [article.image] : []); - const isValid = title.trim().length > 0 && content.trim().length > 0; + const checkValidity = () => { + const valid = + (titleRef.current?.value.trim().length ?? 0) > 0 && + (contentRef.current?.value.trim().length ?? 0) > 0; + setIsValid((prev) => (prev !== valid ? valid : prev)); + }; const handleFileSelect = async (file: File) => { try { @@ -44,13 +49,15 @@ function ArticleEditForm({ articleId, article }: { articleId: number; article: A }; const handleSubmit = () => { - if (!isValid || updateArticleMutation.isPending) return; + const title = titleRef.current?.value.trim() ?? ''; + const content = contentRef.current?.value.trim() ?? ''; + if (!title || !content || updateArticleMutation.isPending) return; updateArticleMutation.mutate( { articleId, body: { - title: title.trim(), - content: content.trim(), + title, + content, ...(images[0] ? { image: images[0] } : {}), }, }, @@ -71,9 +78,10 @@ function ArticleEditForm({ articleId, article }: { articleId: number; article: A 제목 * setTitle(e.target.value)} + defaultValue={article.title} + onChange={checkValidity} /> @@ -82,10 +90,11 @@ function ArticleEditForm({ articleId, article }: { articleId: number; article: A 내용 *