diff --git a/next.config.ts b/next.config.ts index 2a7f5e4f..680c1769 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + experimental: { + scrollRestoration: false, + }, images: { domains: ['team2-app-s3-bucket.s3.ap-northeast-2.amazonaws.com'], remotePatterns: [ @@ -9,6 +12,7 @@ const nextConfig: NextConfig = { hostname: 'www.thecocktaildb.com', }, ], + qualities: [25, 50, 75, 90, 100], }, env: { NPUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, diff --git a/public/1Stars.png b/public/1Stars.png deleted file mode 100644 index 1dad8e04..00000000 Binary files a/public/1Stars.png and /dev/null differ diff --git a/public/1Stars.webp b/public/1Stars.webp deleted file mode 100644 index 7082f36c..00000000 Binary files a/public/1Stars.webp and /dev/null differ diff --git a/public/1star.webp b/public/1star.webp new file mode 100644 index 00000000..ab4e4470 Binary files /dev/null and b/public/1star.webp differ diff --git a/public/2Stars.png b/public/2Stars.png deleted file mode 100644 index 8e3bc8d4..00000000 Binary files a/public/2Stars.png and /dev/null differ diff --git a/public/2Stars.webp b/public/2Stars.webp deleted file mode 100644 index 5a0a1ff7..00000000 Binary files a/public/2Stars.webp and /dev/null differ diff --git a/public/2star.webp b/public/2star.webp new file mode 100644 index 00000000..113fd609 Binary files /dev/null and b/public/2star.webp differ diff --git a/public/CocktailDrop.webp b/public/CocktailDrop.webp new file mode 100644 index 00000000..3f4095e2 Binary files /dev/null and b/public/CocktailDrop.webp differ diff --git a/public/CocktailDrop_4x.webp b/public/CocktailDrop_4x.webp deleted file mode 100644 index 1f400b51..00000000 Binary files a/public/CocktailDrop_4x.webp and /dev/null differ diff --git a/src/app/(no-layout)/layout.tsx b/src/app/(no-layout)/layout.tsx index 6a94947f..79bf3c19 100644 --- a/src/app/(no-layout)/layout.tsx +++ b/src/app/(no-layout)/layout.tsx @@ -1,4 +1,13 @@ +import FooterWrapper from '@/shared/components/footer/FooterWrapper'; +import Header from '@/shared/components/header/Header'; + function NoLayout({ children }: { children: React.ReactNode }) { - return
{children}
; + return ( + <> +
+
{children}
+ + + ); } export default NoLayout; diff --git a/src/app/(no-layout)/page.tsx b/src/app/(no-layout)/page.tsx index 1e94c4b0..e92addb0 100644 --- a/src/app/(no-layout)/page.tsx +++ b/src/app/(no-layout)/page.tsx @@ -2,7 +2,7 @@ import FinalLanding from '@/domains/main/components/FinalLanding'; export default function Home() { return ( -
+
); diff --git a/src/app/(with-layout)/community/[id]/page.tsx b/src/app/(with-layout)/community/[id]/page.tsx index 6d81d88e..6c11e2d1 100644 --- a/src/app/(with-layout)/community/[id]/page.tsx +++ b/src/app/(with-layout)/community/[id]/page.tsx @@ -14,7 +14,6 @@ export async function generateMetadata({ cache: 'no-store', }); const post = await res.json(); - console.log(post); return { title: post.title, description: post.content?.slice(0, 80), diff --git a/src/app/(with-layout)/community/edit/[postId]/page.tsx b/src/app/(with-layout)/community/edit/[postId]/page.tsx index e091ba3f..81edd8fa 100644 --- a/src/app/(with-layout)/community/edit/[postId]/page.tsx +++ b/src/app/(with-layout)/community/edit/[postId]/page.tsx @@ -6,7 +6,6 @@ import { useParams } from 'next/navigation'; function Page() { const params = useParams(); - console.log(params); return (
diff --git a/src/domains/community/api/fetchPost.ts b/src/domains/community/api/fetchPost.ts index 71eb8c62..f69ff24d 100644 --- a/src/domains/community/api/fetchPost.ts +++ b/src/domains/community/api/fetchPost.ts @@ -95,3 +95,12 @@ export async function likePost(postId: number | ParamValue) { }); if (!res.ok) throw new Error('좋아요 실패'); } + +export async function getLikePost(postId: number | ParamValue) { + const res = await fetch(`${getApi}/posts/${postId}/like`, { + method: 'GET', + }); + if (!res.ok) throw new Error('좋아요 실패'); + const data = await res.json(); + return data.data; +} diff --git a/src/domains/community/components/like/LikeBtn.tsx b/src/domains/community/components/like/LikeBtn.tsx index 2b6dc0d9..48220f0e 100644 --- a/src/domains/community/components/like/LikeBtn.tsx +++ b/src/domains/community/components/like/LikeBtn.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; type Props = { size: 'sm' | 'md'; onClick?: () => void; - isClick?: boolean; // 외부에서 제어 + isClick?: boolean | null; // 외부에서 제어 }; function LikeBtn({ size, onClick, isClick = false }: Props) { @@ -13,7 +13,7 @@ function LikeBtn({ size, onClick, isClick = false }: Props) { type="button" className={`${size === 'md' ? 'w-13.75 h-13.75 flex-center border-1 border-white rounded-full' : ''} bg-primary`} aria-label="좋아요 버튼" - aria-pressed={isClick} + aria-pressed={isClick ? isClick : false} onClick={() => { if (onClick) onClick(); }} diff --git a/src/domains/community/components/tag/TagList.tsx b/src/domains/community/components/tag/TagList.tsx index b9a77d8f..6b241a2d 100644 --- a/src/domains/community/components/tag/TagList.tsx +++ b/src/domains/community/components/tag/TagList.tsx @@ -13,12 +13,26 @@ function TagList({ hasDelete, tags, setTags }: Props) { if (!tags) return; return ( -
    +
      {tags?.length > 0 && tags.map((tag) => (
    • {tag}

      {hasDelete && ( diff --git a/src/domains/community/components/textarea/AutoGrowingTextarea.tsx b/src/domains/community/components/textarea/AutoGrowingTextarea.tsx index 8f12a29d..98afe841 100644 --- a/src/domains/community/components/textarea/AutoGrowingTextarea.tsx +++ b/src/domains/community/components/textarea/AutoGrowingTextarea.tsx @@ -1,14 +1,12 @@ -import { Virtualizer } from '@tanstack/react-virtual'; import gsap from 'gsap'; import { useEffect, useRef } from 'react'; type Props = { value: string; onChange: (e: React.ChangeEvent) => void; - rowVirtualize: Virtualizer; }; -function AutoGrowingTextarea({ value, onChange, rowVirtualize }: Props) { +function AutoGrowingTextarea({ value, onChange }: Props) { const textareaRef = useRef(null); useEffect(() => { @@ -29,17 +27,6 @@ function AutoGrowingTextarea({ value, onChange, rowVirtualize }: Props) { }; }, []); - useEffect(() => { - if (textareaRef.current) { - requestAnimationFrame(() => { - const li = textareaRef.current?.closest('li') as HTMLElement | null; - if (li) { - rowVirtualize.measureElement(li); - } - }); - } - }, [value]); - useEffect(() => { if (!textareaRef.current) return; gsap.fromTo( diff --git a/src/domains/community/detail/DetailContent.tsx b/src/domains/community/detail/DetailContent.tsx index 5a5af01e..c9cb679d 100644 --- a/src/domains/community/detail/DetailContent.tsx +++ b/src/domains/community/detail/DetailContent.tsx @@ -12,7 +12,7 @@ type Props = { content: string; prevLikeCount: number; commentCount: number; - like: boolean; + like: boolean | null; onLikeToggle: () => void; imageUrls: string[]; title: string; diff --git a/src/domains/community/detail/DetailPage.tsx b/src/domains/community/detail/DetailPage.tsx index d950eead..42ad188d 100644 --- a/src/domains/community/detail/DetailPage.tsx +++ b/src/domains/community/detail/DetailPage.tsx @@ -1,6 +1,6 @@ 'use client'; -import { fetchPostById, likePost } from '@/domains/community/api/fetchPost'; +import { fetchPostById, getLikePost, 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'; @@ -24,7 +24,7 @@ function DetailPage() { const [postDetail, setPostDetail] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [like, setLike] = useState(false); + const [like, setLike] = useState(null); const [prevLikeCount, setPrevLikeCount] = useState(0); const isLoggedIn = useAuthStore((state) => state.isLoggedIn); @@ -46,6 +46,18 @@ function DetailPage() { fetchData(); }, [postId, setPostDetail]); + useEffect(() => { + const fetchLikeStatus = async () => { + try { + const liked = await getLikePost(postId); + setLike(liked); + } catch (err) { + console.error('좋아요 상태 불러오기 실패', err); + } + }; + fetchLikeStatus(); + }, [postId]); + useEffect(() => { if (postDetail) { setPrevLikeCount(postDetail.likeCount); @@ -68,17 +80,17 @@ function DetailPage() { } = postDetail; const handleLike = async () => { - setLike((prev) => !prev); - setPrevLikeCount((prev) => { - return like ? prev! - 1 : prev! + 1; - }); + const newLike = !like; // 현재 상태 기준으로 먼저 계산 + setLike(newLike); // 좋아요 상태 먼저 반영 + setPrevLikeCount((count) => (newLike ? (count ?? 0) + 1 : (count ?? 0) - 1)); // count도 바로 계산 try { await likePost(postId); // POST 요청 한 번으로 토글 처리 } catch (err) { console.error('좋아요 토글 실패', err); - setLike((prev) => !prev); - setPrevLikeCount((prev) => (like ? prev! + 1 : prev! - 1)); + // 롤백 + setLike(!newLike); + setPrevLikeCount((count) => (newLike ? (count ?? 0) - 1 : (count ?? 0) + 1)); } }; @@ -126,7 +138,7 @@ function DetailPage() { {isLoggedIn && (
      1; + const [loadedImages, setLoadedImages] = useState>(new Set()); + + const handleImageLoad = (imgUrl: string) => { + setLoadedImages((prev) => new Set(prev).add(imgUrl)); + }; + return ( {imageUrls.length > 0 && imageUrls.map((img) => ( - 이미지 +
      + {!loadedImages.has(img) && ( +
      +
      +
      + )} + 이미지 handleImageLoad(img)} + onError={() => handleImageLoad(img)} + className={`object-contain w-full max-h-[400px] transition-opacity duration-300 ${ + loadedImages.has(img) ? 'opacity-100' : 'opacity-0' + }`} + style={{ width: 'auto', height: 'auto' }} + /> +
      ))}
      diff --git a/src/domains/community/detail/tab/DetailTabDesktop.tsx b/src/domains/community/detail/tab/DetailTabDesktop.tsx index 2106232d..275907aa 100644 --- a/src/domains/community/detail/tab/DetailTabDesktop.tsx +++ b/src/domains/community/detail/tab/DetailTabDesktop.tsx @@ -9,10 +9,10 @@ import { useParams } from 'next/navigation'; import { CommentType } from '../../types/post'; type Props = { - likeCount: number; + likeCount: number | undefined; commentCount: number; commentRef: RefObject; - like: boolean; + like: boolean | null; onLikeToggle: () => void; title: string; imageUrls: string[]; @@ -27,7 +27,6 @@ interface Meta { function DetailTabDesktop({ likeCount, - commentCount, commentRef, like, onLikeToggle, diff --git a/src/domains/community/detail/tab/DetailTabMobile.tsx b/src/domains/community/detail/tab/DetailTabMobile.tsx index 1d6f85a9..6543259c 100644 --- a/src/domains/community/detail/tab/DetailTabMobile.tsx +++ b/src/domains/community/detail/tab/DetailTabMobile.tsx @@ -8,7 +8,7 @@ import { useParams } from 'next/navigation'; type Props = { likeCount: number; - like: boolean; + like: boolean | null; onLikeToggle: () => void; title: string; imageUrls: string[]; diff --git a/src/domains/community/main/Community.tsx b/src/domains/community/main/Community.tsx index b000c950..115c90dc 100644 --- a/src/domains/community/main/Community.tsx +++ b/src/domains/community/main/Community.tsx @@ -45,8 +45,6 @@ function Community() { const newPosts = await fetchPostByTab({ category, filter, - lastLikeCount, - lastCommentCount, }); if (!newPosts || newPosts.length === 0) { @@ -55,6 +53,9 @@ function Community() { } else { setPosts(newPosts); } + } catch (error) { + console.error('게시글 로딩 실패:', error); + setPosts([]); } finally { setIsLoading(false); } @@ -63,7 +64,6 @@ function Community() { const loadMorePosts = async (lastPostId: number) => { if (isEnd || isLoading) return; if (!posts || posts.length === 0) return; - console.log('시작', lastPostId); if (lastLoadedId === lastPostId) return; setLastLoadedId(lastPostId); @@ -81,8 +81,14 @@ function Community() { if (!newPosts || newPosts?.length === 0) { setIsEnd(true); } else { - setPosts((prev) => [...(prev ?? []), ...(newPosts ?? [])]); + setPosts((prev) => { + const existingIds = new Set(prev?.map((p) => p.postId)); + const filtered = newPosts.filter((p) => !existingIds.has(p.postId)); + return [...(prev || []), ...filtered]; + }); } + } catch (error) { + console.error('추가 게시글 로딩 실패:', error); } finally { setIsLoading(false); } @@ -94,7 +100,7 @@ function Community() { aria-label="탭과 글쓰기" className="flex justify-between item-center sm:flex-row flex-col gap-4 mt-1" > - + diff --git a/src/domains/community/main/CommunityTab.tsx b/src/domains/community/main/CommunityTab.tsx index af04f43a..a25ea781 100644 --- a/src/domains/community/main/CommunityTab.tsx +++ b/src/domains/community/main/CommunityTab.tsx @@ -1,16 +1,9 @@ 'use client'; import tw from '@/shared/utills/tw'; -import { Post } from '../types/post'; import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -type Props = { - setPosts: (value: Post[] | null) => void; - setIsLoading: (value: boolean) => void; - setIsEnd: (value: boolean) => void; -}; - export const tabItem = [ { key: 'all', label: '전체' }, { key: 'recipe', label: '레시피' }, @@ -19,7 +12,7 @@ export const tabItem = [ { key: 'chat', label: '자유' }, ]; -function CommunityTab({ setPosts, setIsLoading, setIsEnd }: Props) { +function CommunityTab() { const searchParams = useSearchParams(); const router = useRouter(); diff --git a/src/domains/community/main/PostCard.tsx b/src/domains/community/main/PostCard.tsx index eb79f109..08273d75 100644 --- a/src/domains/community/main/PostCard.tsx +++ b/src/domains/community/main/PostCard.tsx @@ -6,7 +6,6 @@ import PostInfo from '../components/post-info/PostInfo'; import Label from '@/domains/shared/components/label/Label'; import { Post } from '@/domains/community/types/post'; import { useRouter } from 'next/navigation'; -import SkeletonPostCard from '@/domains/shared/skeleton/SkeletonPostCard'; import { useInfiniteScrollObserver } from '@/shared/hook/useInfiniteScrollObserver'; import { useRef } from 'react'; @@ -33,8 +32,7 @@ function PostCard({ posts, isLoading, isEnd, onLoadMore }: Props) { onLoadMore: onLoadMore ?? (() => {}), }); - if (isLoading) return ; - if (posts && posts.length === 0) + if (posts && posts.length === 0 && !isLoading) return (
      작성된 글이 없습니다.
      ); @@ -116,6 +114,16 @@ function PostCard({ posts, isLoading, isEnd, onLoadMore }: Props) { ); } )} + + {/* 무한 스크롤 로딩 인디케이터 */} + {isLoading && posts && posts.length > 0 && ( +
      +
      +
      + 더 많은 게시글을 불러오는 중... +
      +
      + )} ); } diff --git a/src/domains/community/main/WriteBtn.tsx b/src/domains/community/main/WriteBtn.tsx index 6b60af71..9c5c1692 100644 --- a/src/domains/community/main/WriteBtn.tsx +++ b/src/domains/community/main/WriteBtn.tsx @@ -3,14 +3,21 @@ import Write from '@/shared/assets/icons/edit_28.svg'; import { useRouter } from 'next/navigation'; import Button from '@/shared/components/button/Button'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import { useToast } from '@/shared/hook/useToast'; type RouterType = ReturnType; function WriteBtn() { const router = useRouter(); + const isLoggedIn = useAuthStore((state) => state.isLoggedIn); + const { toastError } = useToast(); const handleClick = (router: RouterType) => { - router.push('/community/write'); + if (!isLoggedIn) { + toastError('로그인 후에 이용 가능합니다.'); + return; + } else router.push('/community/write'); }; return ( diff --git a/src/domains/community/write/CompleteBtn.tsx b/src/domains/community/write/CompleteBtn.tsx index a8097353..9c7c913b 100644 --- a/src/domains/community/write/CompleteBtn.tsx +++ b/src/domains/community/write/CompleteBtn.tsx @@ -3,10 +3,9 @@ import Button from '@/shared/components/button/Button'; type Props = { mode: 'edit' | 'create'; setEditDone: (value: boolean) => void; - handleEditLogic: () => Promise; }; -function CompleteBtn({ mode, setEditDone, handleEditLogic }: Props) { +function CompleteBtn({ mode, setEditDone }: Props) { return (
      ); diff --git a/src/domains/community/write/WriteForm.tsx b/src/domains/community/write/WriteForm.tsx index a61ff82a..4381b9cd 100644 --- a/src/domains/community/write/WriteForm.tsx +++ b/src/domains/community/write/WriteForm.tsx @@ -1,5 +1,5 @@ import { FormType } from '@/domains/recipe/types/types'; -import { Dispatch, SetStateAction, useRef } from 'react'; +import { Dispatch, SetStateAction } from 'react'; type Props = { formData: FormType; @@ -7,8 +7,6 @@ type Props = { }; function WriteForm({ formData, setFormData }: Props) { - const divRef = useRef(null); - return (
      -
        +
          {tags && tags.length > 0 ? ( tags.map((tag) => (
          diff --git a/src/domains/community/write/image-upload/ImageInput.tsx b/src/domains/community/write/image-upload/ImageInput.tsx index b67fe25a..a0206e74 100644 --- a/src/domains/community/write/image-upload/ImageInput.tsx +++ b/src/domains/community/write/image-upload/ImageInput.tsx @@ -1,16 +1,13 @@ -import { getApi } from '@/app/api/config/appConfig'; import { UploadedItem } from '@/domains/recipe/types/types'; import ImageBox from '@/shared/assets/icons/imageBox_fill_24.svg'; import { useToast } from '@/shared/hook/useToast'; -import { Dispatch, SetStateAction } from 'react'; type Props = { uploadedFile: UploadedItem[]; - setUploadedFile: Dispatch>; onAddImage: (newFiles: UploadedItem[]) => void; }; -function ImageInput({ uploadedFile, setUploadedFile, onAddImage }: Props) { +function ImageInput({ uploadedFile, onAddImage }: Props) { const { toastError } = useToast(); const handleInputChange = async (e: React.ChangeEvent) => { @@ -20,23 +17,22 @@ function ImageInput({ uploadedFile, setUploadedFile, onAddImage }: Props) { const newFiles = Array.from(newFileList); try { - const urls: string[] = await uploadFiles(newFiles); - - const newItems: UploadedItem[] = urls.map((url, i) => ({ - file: newFiles[i], - url, - })); - - const totalLength = uploadedFile.length + newItems.length; - + const totalLength = uploadedFile.length + newFiles.length; if (totalLength > 10) { toastError('최대 10개 파일까지 업로드할 수 있어요.'); return; } - // 중복 제거 - const existingUrls = new Set(uploadedFile.map((item) => item.url)); - const filteredItems = newItems.filter((item) => !existingUrls.has(item.url)); + const existingIdentifiers = new Set( + uploadedFile.map((item) => (item.file ? `${item.file.name}-${item.file.size}` : item.url)) + ); + const filteredItems = newFiles + .filter((file) => !existingIdentifiers.has(`${file.name}-${file.size}`)) + .map((file) => ({ + file, + url: URL.createObjectURL(file), // 미리보기용 + isNew: true, + })); if (filteredItems.length === 0) { toastError('이미 업로드된 파일입니다.'); @@ -49,28 +45,6 @@ function ImageInput({ uploadedFile, setUploadedFile, onAddImage }: Props) { } }; - const uploadFiles = async (files: File[]): Promise => { - if (files.length === 0) return []; - - const formData = new FormData(); - files.forEach((file) => formData.append('file', file)); - - const res = await fetch(`${getApi}/file/upload`, { - method: 'POST', - body: formData, - }); - - if (!res.ok) throw new Error('파일 업로드 실패'); - - const data = await res.json(); - const urlRegex = /(https?:\/\/[^\s]+)/g; - const matched = data.data.match(urlRegex); - - if (!matched) throw new Error('URL 파싱 실패'); - - return matched; - }; - return ( <>