diff --git a/next.config.ts b/next.config.ts index 121b43bc..5dce33cc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { images: { + domains: ['team2-app-s3-bucket.s3.ap-northeast-2.amazonaws.com'], remotePatterns: [ { protocol: 'https', diff --git a/package-lock.json b/package-lock.json index 0826c9fa..ff6b65a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", "react-intersection-observer": "^9.16.0", - "react-use": "^17.6.0" + "react-use": "^17.6.0", + "swiper": "^12.0.2" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -9250,6 +9251,25 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/swiper": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.0.2.tgz", + "integrity": "sha512-y8F6fDGXmTVVgwqJj6I00l4FdGuhpFJn0U/9Ucn1MwWOw3NdLV8aH88pZOjyhBgU/6PyBlUx+JuAQ5KMWz906Q==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", diff --git a/package.json b/package.json index 1ce98419..4eaef365 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,9 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", - "react-intersection-observer": "^9.16.0", - "react-use": "^17.6.0" + "react-use": "^17.6.0", + "swiper": "^12.0.2", + "react-intersection-observer": "^9.16.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src.zip b/src.zip deleted file mode 100644 index 143176bd..00000000 Binary files a/src.zip and /dev/null differ diff --git a/src/app/community/[id]/page.tsx b/src/app/community/[id]/page.tsx index c80776f9..6d81d88e 100644 --- a/src/app/community/[id]/page.tsx +++ b/src/app/community/[id]/page.tsx @@ -1,75 +1,48 @@ -'use client'; - -import { fetchPostById } from '@/domains/community/api/fetchPost'; -import DetailContent from '@/domains/community/detail/DetailContent'; -import DetailHeader from '@/domains/community/detail/DetailHeader'; -import DetailTitle from '@/domains/community/detail/DetailTitle'; -import DetailTabDesktop from '@/domains/community/detail/tab/DetailTabDesktop'; -import { Post } from '@/domains/community/types/post'; -import Comment from '@/domains/community/detail/Comment'; -import StarBg from '@/domains/shared/components/star-bg/StarBg'; -import { useParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import DetailSkeleton from '@/domains/community/detail/DetailSkeleton'; +import { Metadata } from 'next'; +import { getApi } from '@/app/api/config/appConfig'; +import DetailPage from '@/domains/community/detail/DetailPage'; + +type RouteParams = { id: number }; + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { id } = await params; + const res = await fetch(`${getApi}/posts/${id}`, { + cache: 'no-store', + }); + const post = await res.json(); + console.log(post); + return { + title: post.title, + description: post.content?.slice(0, 80), + openGraph: { + title: post.title, + description: post.content?.slice(0, 80), + url: `https://your-domain.com/community/${id}`, + images: [ + { + url: post.imageUrls?.[0], + width: 800, + height: 600, + alt: post.title, + }, + ], + type: 'article', + }, + twitter: { + card: 'summary_large_image', + title: post.title, + description: post.content?.slice(0, 80), + images: [post.imageUrls?.[0]], + }, + }; +} function Page() { - const params = useParams(); - const [postDetail, setPostDetail] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const postId = params.id; - const fetchData = async () => { - setIsLoading(true); - const data = await fetchPostById(postId); - if (!data) return; - - setPostDetail(data); - setIsLoading(false); - }; - fetchData(); - }, [params.id, setPostDetail]); - - if (isLoading) return ; - if (!postDetail) return null; - - const { - categoryName, - title, - userNickName, - createdAt, - viewCount, - postId, - tags, - content, - likeCount, - commentCount, - } = postDetail; - - return ( -
- -
- - - -
- -
-
-
- -
-
- ); + return ; } export default Page; diff --git a/src/app/community/edit/[postId]/page.tsx b/src/app/community/edit/[postId]/page.tsx new file mode 100644 index 00000000..81edd8fa --- /dev/null +++ b/src/app/community/edit/[postId]/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import WriteSection from '@/domains/community/write/WriteSection'; +import StarBg from '@/domains/shared/components/star-bg/StarBg'; +import { useParams } from 'next/navigation'; + +function Page() { + const params = useParams(); + + return ( +
+ +
+ +
+
+ ); +} + +export default Page; diff --git a/src/app/community/page.tsx b/src/app/community/page.tsx index 5a5ec3ba..69c710e5 100644 --- a/src/app/community/page.tsx +++ b/src/app/community/page.tsx @@ -12,7 +12,7 @@ function Page() {
-
+

커뮤니티 페이지 diff --git a/src/app/community/write/page.tsx b/src/app/community/write/page.tsx index 839ee012..c055db77 100644 --- a/src/app/community/write/page.tsx +++ b/src/app/community/write/page.tsx @@ -1,34 +1,15 @@ 'use client'; -import Tag from '@/domains/community/components/tag/Tag'; -import Category from '@/domains/community/write/Category'; -import TagModal from '@/domains/community/write/cocktail-tag/TagModal'; -import CompleteBtn from '@/domains/community/write/CompleteBtn'; -import FormTitle from '@/domains/community/write/FormTitle'; -import ImageSection from '@/domains/community/write/image-upload/ImageSection'; -import WriteForm from '@/domains/community/write/WriteForm'; +import WriteSection from '@/domains/community/write/WriteSection'; import StarBg from '@/domains/shared/components/star-bg/StarBg'; -import { useState } from 'react'; function Page() { - const [isOpen, setIsOpen] = useState(false); - return (
- -
- - - -
- -
- setIsOpen(true)} /> -
+
- {isOpen && }
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1097658b..f35edec6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,9 @@ import Header from '@/shared/components/header/Header'; import FooterWrapper from '@/shared/components/footer/FooterWrapper'; import ScrollTopBtnWrapper from '@/shared/components/scroll-top/ScrollTopBtnWrapper'; import KaKaoScript from './api/kakao/KaKaoScript'; +import 'swiper/css'; +import 'swiper/css/navigation'; +import 'swiper/css/pagination'; import Provider from '@/shared/api/Provider'; import ClientInitHook from '@/domains/login/components/ClientInitHook'; diff --git a/src/domains/community/api/fetchCocktails.ts b/src/domains/community/api/fetchCocktails.ts new file mode 100644 index 00000000..b504405b --- /dev/null +++ b/src/domains/community/api/fetchCocktails.ts @@ -0,0 +1,12 @@ +import { getApi } from '@/app/api/config/appConfig'; + +export const fetchCocktails = async () => { + try { + const res = await fetch(`${getApi}/cocktails`); + if (!res.ok) throw new Error('칵테일 데이터 불러오기 실패'); + const data = await res.json(); + return data.data; + } catch (error) { + console.error(error); + } +}; diff --git a/src/domains/community/api/fetchComment.ts b/src/domains/community/api/fetchComment.ts index 2eb8302d..4d66342c 100644 --- a/src/domains/community/api/fetchComment.ts +++ b/src/domains/community/api/fetchComment.ts @@ -1,7 +1,8 @@ import { getApi } from '@/app/api/config/appConfig'; import { CommentType } from '../types/post'; +import { ParamValue } from 'next/dist/server/request/params'; -export const fetchComment = async (postId: number): Promise => { +export const fetchComment = async (postId: ParamValue | number): Promise => { try { const res = await fetch(`${getApi}/posts/${postId}/comments`, { method: 'GET', @@ -21,7 +22,7 @@ export const fetchComment = async (postId: number): Promise { +export const postComments = async (postId: number | ParamValue, content: string) => { try { const res = await fetch(`${getApi}/posts/${postId}/comments`, { method: 'POST', @@ -48,7 +49,7 @@ export const postComments = async (postId: number, content: string) => { export async function updateComment( accessToken: string | null, - postId: number, + postId: number | ParamValue, commentId: number, content: string ): Promise { @@ -70,7 +71,7 @@ export async function updateComment( export async function deleteComment( accessToken: string | null, - postId: number, + postId: number | ParamValue, commentId: number ): Promise { const response = await fetch(`${getApi}/posts/${postId}/comments/${commentId}`, { diff --git a/src/domains/community/api/fetchPost.ts b/src/domains/community/api/fetchPost.ts index 3cab12ed..71eb8c62 100644 --- a/src/domains/community/api/fetchPost.ts +++ b/src/domains/community/api/fetchPost.ts @@ -20,7 +20,7 @@ export const fetchPost = async (lastId?: number | null): Promise } }; -export const fetchPostById = async (postId: ParamValue) => { +export const fetchPostById = async (postId: ParamValue | number) => { try { const res = await fetch(`${getApi}/posts/${postId}`, { method: 'GET', @@ -87,3 +87,11 @@ export const fetchPostByTab = async ({ return null; } }; + +export async function likePost(postId: number | ParamValue) { + const res = await fetch(`${getApi}/posts/${postId}/like`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) throw new Error('좋아요 실패'); +} diff --git a/src/domains/community/components/comment/CommentBtn.tsx b/src/domains/community/components/comment/CommentBtn.tsx index b3f497f4..fef0f4d8 100644 --- a/src/domains/community/components/comment/CommentBtn.tsx +++ b/src/domains/community/components/comment/CommentBtn.tsx @@ -1,19 +1,25 @@ import CommentIcon from '@/shared/assets/icons/comment_28.svg'; import { useState } from 'react'; -function CommentBtn({ size }: { size: 'sm' | 'md' }) { +type Props = { + size: 'sm' | 'md'; + onClick?: () => void; +}; + +function CommentBtn({ size, onClick }: Props) { const [isClick, setIsClick] = useState(false); const handleClick = () => { setIsClick(!isClick); }; + return ( diff --git a/src/domains/community/detail/Comment.tsx b/src/domains/community/detail/Comment.tsx index 7c60a4e2..f7009de8 100644 --- a/src/domains/community/detail/Comment.tsx +++ b/src/domains/community/detail/Comment.tsx @@ -5,9 +5,10 @@ import { useAuthStore } from '@/domains/shared/store/auth'; import { useShallow } from 'zustand/shallow'; import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal'; import { useComments } from '../hook/useComment'; +import { ParamValue } from 'next/dist/server/request/params'; type Props = { - postId: number; + postId: ParamValue; }; function Comment({ postId }: Props) { @@ -32,7 +33,7 @@ function Comment({ postId }: Props) { return ( <> -
+
void; + imageUrls: string[]; + title: string; }; -function DetailContent({ createdAt, viewCount, tags, content, likeCount, commentCount }: Props) { +function DetailContent({ + createdAt, + viewCount, + tags, + content, + prevLikeCount, + commentCount, + title, + like, + imageUrls, + onLikeToggle, +}: Props) { return (
-
- 더미 이미지 -
-
{content}
- + +
+ {content} +
+
- +
); diff --git a/src/domains/community/detail/DetailHeader.tsx b/src/domains/community/detail/DetailHeader.tsx index 1e675e69..441bcd8e 100644 --- a/src/domains/community/detail/DetailHeader.tsx +++ b/src/domains/community/detail/DetailHeader.tsx @@ -1,16 +1,68 @@ import Label from '@/domains/shared/components/label/Label'; import EditDelete from './EditDelete'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import { useToast } from '@/shared/hook/useToast'; +import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal'; +import { useState } from 'react'; +import { getApi } from '@/app/api/config/appConfig'; +import { ParamValue } from 'next/dist/server/request/params'; type Props = { categoryName: string; + postId: number | ParamValue; + userNickName: string; }; -function DetailHeader({ categoryName }: Props) { +function DetailHeader({ categoryName, postId, userNickName }: Props) { + const [deletePost, setDeletePost] = useState(false); + const router = useRouter(); + const user = useAuthStore((state) => state.user); + const { toastError } = useToast(); + + const handleConfirmDelete = async (postId: number | ParamValue) => { + if (!user) { + alert('로그인이 필요합니다'); + return; + } + + try { + const res = await fetch(`${getApi}/posts/${postId}`, { method: 'DELETE' }); + if (res.ok) console.log('deleted'); + router.push('/community'); + } catch (err) { + console.error(err); + alert('글 삭제 중 오류가 발생했습니다.'); + } + }; + return ( -
-
+ <> +
+
+ {deletePost && ( + handleConfirmDelete(postId)} + onCancel={() => setDeletePost(false)} + onClose={() => setDeletePost(false)} + title="글 삭제" + description="정말 이 글을 삭제하시겠습니까?" + /> + )} + ); } diff --git a/src/domains/community/detail/DetailPage.tsx b/src/domains/community/detail/DetailPage.tsx new file mode 100644 index 00000000..791c0f05 --- /dev/null +++ b/src/domains/community/detail/DetailPage.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { fetchPostById, likePost } from '@/domains/community/api/fetchPost'; +import DetailContent from '@/domains/community/detail/DetailContent'; +import DetailHeader from '@/domains/community/detail/DetailHeader'; +import DetailTitle from '@/domains/community/detail/DetailTitle'; +import DetailTabDesktop from '@/domains/community/detail/tab/DetailTabDesktop'; +import { Post } from '@/domains/community/types/post'; +import Comment from '@/domains/community/detail/Comment'; +import StarBg from '@/domains/shared/components/star-bg/StarBg'; +import { useEffect, useRef, useState } from 'react'; +import DetailSkeleton from '@/domains/community/detail/DetailSkeleton'; +import { useParams } from 'next/navigation'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import Button from '@/shared/components/button/Button'; +import { useRouter } from 'next/navigation'; + +function DetailPage() { + const params = useParams(); + const postId = params.id; + + const [postDetail, setPostDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [like, setLike] = useState(false); + const [prevLikeCount, setPrevLikeCount] = useState(0); + + const isLoggedIn = useAuthStore((state) => state.isLoggedIn); + const router = useRouter(); + + const commentRef = useRef(null); + + useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + const data = await fetchPostById(postId); + if (!data) return; + + setPostDetail(data); + setIsLoading(false); + }; + fetchData(); + }, [postId, setPostDetail]); + + useEffect(() => { + if (postDetail) { + setPrevLikeCount(postDetail.likeCount); + } + }, [postDetail]); + + if (isLoading) return ; + if (!postDetail) return null; + + const { + categoryName, + title, + userNickName, + createdAt, + viewCount, + imageUrls, + tags, + content, + commentCount, + } = postDetail; + + const handleLike = async () => { + setLike((prev) => !prev); + setPrevLikeCount((prev) => { + return like ? prev! - 1 : prev! + 1; + }); + + try { + await likePost(postId); // POST 요청 한 번으로 토글 처리 + } catch (err) { + console.error('좋아요 토글 실패', err); + setLike((prev) => !prev); + setPrevLikeCount((prev) => (like ? prev! + 1 : prev! - 1)); + } + }; + + return ( + <> +
+ +
+ + + + {isLoggedIn ? ( + <> + + + ) : ( +
+
+
+

+ 이 게시글을 보시려면 로그인이 필요합니다. +

+ +
+
+ )} +
+ +
+
+ {isLoggedIn && ( +
+ +
+ )} +
+ + ); +} + +export default DetailPage; diff --git a/src/domains/community/detail/EditDelete.tsx b/src/domains/community/detail/EditDelete.tsx index 237b833c..6517897e 100644 --- a/src/domains/community/detail/EditDelete.tsx +++ b/src/domains/community/detail/EditDelete.tsx @@ -12,7 +12,7 @@ function EditDelete({ use, isEditing, onEdit, onCancelEdit, onDelete, onSubmitEd
+ {isShare && meta && ( + setIsShare(!isShare)} + src={meta.imageUrl} + title={meta.title} + url={meta.url} + /> + )} + ); } diff --git a/src/domains/community/detail/tab/DetailTabMobile.tsx b/src/domains/community/detail/tab/DetailTabMobile.tsx index f9f00f87..fffca533 100644 --- a/src/domains/community/detail/tab/DetailTabMobile.tsx +++ b/src/domains/community/detail/tab/DetailTabMobile.tsx @@ -2,27 +2,71 @@ import Share from '@/domains/shared/components/share/Share'; import LikeBtn from '../../components/like/LikeBtn'; +import { useState } from 'react'; +import ShareModal from '@/domains/shared/components/share/ShareModal'; type Props = { likeCount: number; + like: boolean; + onLikeToggle: () => void; + title: string; + imageUrls: string[]; }; -function DetailTabMobile({ likeCount }: Props) { +interface Meta { + title: string; + imageUrl: string | undefined; + url: string; +} + +function DetailTabMobile({ likeCount, onLikeToggle, like, title, imageUrls }: Props) { + const [isShare, setIsShare] = useState(false); + const [meta, setMeta] = useState(null); + + const handleShareClick = () => { + if (typeof window !== 'undefined') { + const currentUrl = window.location.href; + setMeta({ + title, + url: currentUrl, + imageUrl: imageUrls[0] || getOgImage(), + }); + setIsShare(true); + } + }; + + // ✅ og:image 메타태그에서 이미지 가져오기 (fallback용) + const getOgImage = (): string | undefined => { + const ogImage = document.querySelector('meta[property="og:image"]'); + return ogImage?.getAttribute('content') || undefined; + }; + return ( -
-
-
- - {likeCount} -
-
- + <> +
+
+
+ + {likeCount} +
+
+ +
-
-
+
+ {isShare && meta && ( + setIsShare(!isShare)} + src={meta.imageUrl} + title={meta.title} + url={meta.url} + /> + )} + ); } diff --git a/src/domains/community/hook/useComment.ts b/src/domains/community/hook/useComment.ts index 15b8a63c..ad77a19e 100644 --- a/src/domains/community/hook/useComment.ts +++ b/src/domains/community/hook/useComment.ts @@ -3,14 +3,16 @@ import { deleteComment, fetchComment, updateComment } from '../api/fetchComment' import { getApi } from '@/app/api/config/appConfig'; import { CommentType } from '../types/post'; import { User } from '@/domains/shared/store/auth'; +import { ParamValue } from 'next/dist/server/request/params'; -export function useComments(postId: number, user: User | null, accessToken: string | null) { +export function useComments(postId: ParamValue, user: User | null, accessToken: string | null) { const [comments, setComments] = useState(null); const [isEnd, setIsEnd] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [deleteTarget, setDeleteTarget] = useState<{ commentId: number; postId: number } | null>( - null - ); + const [deleteTarget, setDeleteTarget] = useState<{ + commentId: number; + postId: number | ParamValue; + } | null>(null); const fetchData = useCallback(async () => { const data = await fetchComment(postId); diff --git a/src/domains/community/main/Community.tsx b/src/domains/community/main/Community.tsx index 008aab1d..b000c950 100644 --- a/src/domains/community/main/Community.tsx +++ b/src/domains/community/main/Community.tsx @@ -22,6 +22,12 @@ function Community() { [searchParams] ); + const lastLikeCount = + posts && posts.length > 0 ? Math.min(...posts.map((post) => post.likeCount)) : null; + + const lastCommentCount = + posts && posts.length > 0 ? Math.min(...posts.map((post) => post.commentCount)) : null; + const [isEnd, setIsEnd] = useState(false); useEffect(() => { @@ -32,19 +38,9 @@ function Community() { }, [category, filter]); const loadInitialPosts = async () => { - const category = searchParams.get('category') || 'all'; - const filter = - (searchParams.get('postSortStatus') as 'LATEST' | 'POPULAR' | 'COMMENTS') || 'LATEST'; - setIsLoading(true); setIsEnd(false); - const lastLikeCount = - posts && posts.length > 0 ? Math.min(...posts.map((post) => post.likeCount)) : null; - - const lastCommentCount = - posts && posts.length > 0 ? Math.min(...posts.map((post) => post.commentCount)) : null; - try { const newPosts = await fetchPostByTab({ category, @@ -69,25 +65,21 @@ function Community() { if (!posts || posts.length === 0) return; console.log('시작', lastPostId); - const lastPost = posts[posts.length - 1]; - if (lastPostId === lastPost.postId) return; - setLastLoadedId(lastPost.postId); + if (lastLoadedId === lastPostId) return; + setLastLoadedId(lastPostId); setIsLoading(true); try { - const category = searchParams.get('category') || 'all'; - const filter = - (searchParams.get('postSortStatus') as 'LATEST' | 'POPULAR' | 'COMMENTS') || 'LATEST'; - const newPosts = await fetchPostByTab({ category, filter, + lastLikeCount, + lastCommentCount, lastId: lastPostId, }); if (!newPosts || newPosts?.length === 0) { setIsEnd(true); - console.log('끝'); } else { setPosts((prev) => [...(prev ?? []), ...(newPosts ?? [])]); } diff --git a/src/domains/community/main/CommunityTab.tsx b/src/domains/community/main/CommunityTab.tsx index a277cc52..af04f43a 100644 --- a/src/domains/community/main/CommunityTab.tsx +++ b/src/domains/community/main/CommunityTab.tsx @@ -3,7 +3,6 @@ import tw from '@/shared/utills/tw'; import { Post } from '../types/post'; import { useState } from 'react'; -import { fetchPost, fetchPostByTab } from '../api/fetchPost'; import { useRouter, useSearchParams } from 'next/navigation'; type Props = { diff --git a/src/domains/community/main/PostCard.tsx b/src/domains/community/main/PostCard.tsx index b1fe3878..755cf0d5 100644 --- a/src/domains/community/main/PostCard.tsx +++ b/src/domains/community/main/PostCard.tsx @@ -1,7 +1,6 @@ 'use client'; import Image from 'next/image'; -import prePost from '@/shared/assets/images/prepost_img.webp'; import PostInfo from '../components/post-info/PostInfo'; import Label from '@/domains/shared/components/label/Label'; @@ -17,7 +16,7 @@ type Props = { isLoading: boolean; setIsLoading?: (value: boolean) => void; isEnd?: boolean; - onLoadMore?: (lastCommentId: number) => void; + onLoadMore?: (lastPostId: number) => Promise; }; function PostCard({ posts, isLoading, isEnd, onLoadMore }: Props) { @@ -54,7 +53,7 @@ function PostCard({ posts, isLoading, isEnd, onLoadMore }: Props) { viewCount, createdAt, commentCount, - imageUrl, + imageUrls, }, index ) => { @@ -65,7 +64,9 @@ function PostCard({ posts, isLoading, isEnd, onLoadMore }: Props) { key={postId} ref={(el) => { if (index === 0) firstItemRef.current = el; - if (isLast) observeLastItem(el); + if (isLast) { + observeLastItem(el); + } }} >