From deb0eb02ee5bdf90e9130c7e514ba71ce1096e99 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 3 Apr 2026 17:29:49 +0900 Subject: [PATCH 01/43] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/header/Header.tsx | 15 ++++-- src/hooks/board/useCreatePost.ts | 63 +++++++++++++++++++++++++ src/hooks/index.ts | 1 + src/lib/actions/board.ts | 8 ++++ src/lib/apis/board.server.ts | 6 ++- src/types/board.ts | 21 +++++++++ 6 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/hooks/board/useCreatePost.ts diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index e7ca3412..1b3abc5e 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { usePathname } from 'next/navigation'; import { Button, Icon } from '../../ui'; import { MenuIcon, EditIcon, SendIcon, ExitToAppIcon, AvatarIcon, LogoIcon } from '@/assets/icons'; +import { useCreatePost } from '@/hooks'; interface HeaderProps { isMain?: boolean; @@ -26,12 +27,12 @@ const Logo = ({ width = 76, href }: { width?: number; href: string }) => ( export default function Header({ isMain = true }: HeaderProps) { const pathname = usePathname(); const router = useRouter(); + const isWritePage = pathname.includes('/write'); + const { submit, isPending } = useCreatePost(); const navItems = [ { id: 'board', label: '게시판', href: '/board' }, { id: 'attendance', label: '출석', href: '/attendance' }, ]; - const isWritePage = pathname.includes('/write'); - return ( <>
@@ -91,9 +92,15 @@ export default function Header({ isMain = true }: HeaderProps) { > 작성 취소 - ) : ( diff --git a/src/hooks/board/useCreatePost.ts b/src/hooks/board/useCreatePost.ts new file mode 100644 index 00000000..2ddbcd64 --- /dev/null +++ b/src/hooks/board/useCreatePost.ts @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { createPost } from '@/lib/actions/board'; +import { useClubId } from '@/stores/useClubStore'; +import { usePostStore } from '@/stores/usePostStore'; +import { toast } from '@/stores/useToastStore'; + +/** + * 게시글 작성 Server Action 호출 훅 + * + * - usePostStore에서 board, payload를 꺼내 createPost Server Action 호출 + * - 성공 시 store reset + 게시글 상세로 이동 + * - 업로드 미완료 파일이 있으면 제출 차단 + */ +export function useCreatePost() { + const router = useRouter(); + const clubId = useClubId(); + const [isPending, setIsPending] = useState(false); + + const submit = async () => { + const { board, title, content, files, getPayload, reset } = usePostStore.getState(); + + if (!clubId) { + toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); + return; + } + + if (!board) { + toast({ title: '게시판을 선택해주세요.', variant: 'error' }); + return; + } + + if (!title.trim()) { + toast({ title: '제목을 입력해주세요.', variant: 'error' }); + return; + } + + if (!content.trim()) { + toast({ title: '내용을 입력해주세요.', variant: 'error' }); + return; + } + + const uploading = files.some((f) => !f.uploaded && f.file); + if (uploading) { + toast({ title: '파일 업로드가 진행 중입니다.', variant: 'error' }); + return; + } + + setIsPending(true); + try { + const payload = getPayload(); + const result = await createPost(clubId, board, payload); + reset(); + router.push(`/board/${result.id}`); + } catch { + toast({ title: '게시글 작성에 실패했습니다.', variant: 'error' }); + } finally { + setIsPending(false); + } + }; + + return { submit, isPending }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 93f502a0..838b80b1 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,5 +8,6 @@ export { useScrollIntoView } from './useScrollIntoView'; export { useRemainingTime } from './useRemainingTime'; export { useScrollOnGrow } from './useScrollOnGrow'; export { useBoardList, useBoardPosts } from './board/useBoardQuery'; +export { useCreatePost } from './board/useCreatePost'; export { useIntersectionObserver } from './board/useIntersectionObserver'; export { useProgressAnimation } from './useProgressAnimation'; diff --git a/src/lib/actions/board.ts b/src/lib/actions/board.ts index eaaeb62e..1abfd5f7 100644 --- a/src/lib/actions/board.ts +++ b/src/lib/actions/board.ts @@ -2,8 +2,16 @@ import { revalidatePath } from 'next/cache'; import { boardServerApi } from '@/lib/apis/board.server'; +import type { CreatePostBody } from '@/types/board'; export async function readAllNotices(clubId: string, boardId: number) { await boardServerApi.readAllNotices(clubId, boardId); revalidatePath('/board', 'layout'); } + +export async function createPost(clubId: string, boardId: number, body: CreatePostBody) { + const response = await boardServerApi.createPost(clubId, boardId, body); + revalidatePath('/board', 'layout'); + console.log('Post created:', response.data); + return response.data; +} diff --git a/src/lib/apis/board.server.ts b/src/lib/apis/board.server.ts index 0ea0aed3..69e39f9f 100644 --- a/src/lib/apis/board.server.ts +++ b/src/lib/apis/board.server.ts @@ -1,6 +1,6 @@ import { apiServer } from '@/lib/apis/server'; import type { ApiResponse } from '@/types/common'; -import type { Board, PostDetail } from '@/types/board'; +import type { Board, CreatePostBody, CreatePostData, PostDetail } from '@/types/board'; export const boardServerApi = { /** 게시판 목록 조회 (RSC) — 거의 변하지 않으므로 30분 캐싱 */ @@ -18,4 +18,8 @@ export const boardServerApi = { /** 공지 읽음 처리 (Server Action) */ readAllNotices: (clubId: string, boardId: number) => apiServer.post>(`/clubs/${clubId}/boards/${boardId}/notices/read-all`), + + /** 게시글 작성 (Server Action) */ + createPost: (clubId: string, boardId: number, body: CreatePostBody) => + apiServer.post>(`/clubs/${clubId}/boards/${boardId}/posts`, body), }; diff --git a/src/types/board.ts b/src/types/board.ts index 7d9f4cee..58fda951 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -78,10 +78,31 @@ export interface PostComment { } export interface PostDetail extends PostBase { + isNew?: boolean; comments: PostComment[]; fileUrls: FileItem[]; } +/** 게시글 작성 요청 파일 */ +export interface CreatePostFile { + fileName: string; + storageKey: string; + fileSize: number; + contentType: string; +} + +/** 게시글 작성 요청 body */ +export interface CreatePostBody { + title: string; + content: string; + files: CreatePostFile[]; +} + +/** 게시글 작성 응답 data */ +export interface CreatePostData { + id: number; +} + /** mapComment 변환 결과 (UI 표시용) */ export interface MappedComment { id: number; From 4b289b05aa8bb068962d65f2f045b01aecfda62f Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 3 Apr 2026 17:30:59 +0900 Subject: [PATCH 02/43] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/board/write/ClientEditor.tsx | 20 ++++++++++++------- src/components/board/CategorySelector.tsx | 2 +- src/components/board/Editor/index.tsx | 19 +----------------- src/hooks/board/useFileAttach.ts | 2 ++ src/hooks/useFileUpload.ts | 2 ++ src/stores/usePostStore.ts | 16 +++++++++------ 6 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/app/(private)/(main)/board/write/ClientEditor.tsx b/src/app/(private)/(main)/board/write/ClientEditor.tsx index a6104027..e25e746e 100644 --- a/src/app/(private)/(main)/board/write/ClientEditor.tsx +++ b/src/app/(private)/(main)/board/write/ClientEditor.tsx @@ -7,31 +7,37 @@ import { TitleInput, CategorySelector } from '@/components/board'; import { useBoardList } from '@/hooks'; import { toBoardNavItem } from '@/lib/board'; import { usePostStore } from '@/stores/usePostStore'; +import { useActiveBoardId } from '@/stores/useBoardNavStore'; const Editor = dynamic(() => import('@/components/board/Editor'), { ssr: false }); export default function ClientEditor() { const { data: boards } = useBoardList(); const items = boards?.map(toBoardNavItem) ?? []; + const activeBoardId = useActiveBoardId(); const board = usePostStore((s) => s.board); const setBoard = usePostStore((s) => s.setBoard); + const reset = usePostStore((s) => s.reset); + const title = usePostStore((s) => s.title); + const setTitle = usePostStore((s) => s.setTitle); - const defaultId = items.find((item) => item.type === 'ALL')?.id ?? items[0]?.id ?? null; - + // 글쓰기 페이지 진입 시 store 초기화 후 현재 게시판으로 설정 useEffect(() => { - if (board === null && defaultId !== null) { - setBoard(defaultId); + reset(); + if (activeBoardId !== null) { + setBoard(activeBoardId); } - }, [board, defaultId, setBoard]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- 마운트 시 1회만 실행 + }, []); - const activeId = board ?? defaultId; + const activeId = board ?? activeBoardId ?? items.find((item) => item.type !== 'ALL')?.id ?? null; return (
- + setTitle(e.target.value)} />
diff --git a/src/components/board/CategorySelector.tsx b/src/components/board/CategorySelector.tsx index 5181baaf..3aa90e9d 100644 --- a/src/components/board/CategorySelector.tsx +++ b/src/components/board/CategorySelector.tsx @@ -66,7 +66,7 @@ function CategorySelector({ className, items, activeId, onItemSelect }: Category > item.type !== 'ALL')} activeId={activeId} onItemSelect={handleItemSelect} /> diff --git a/src/components/board/Editor/index.tsx b/src/components/board/Editor/index.tsx index 0def6803..126a4608 100644 --- a/src/components/board/Editor/index.tsx +++ b/src/components/board/Editor/index.tsx @@ -52,27 +52,10 @@ export default function Editor() { processFiles, }); - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - const droppedFiles = Array.from(e.dataTransfer.files); - if (droppedFiles.length > 0) { - processFiles(droppedFiles); - } - }; - if (!editor) return null; return ( -
+
{/* 숨겨진 파일 input — 슬래시 메뉴에서 각 ref를 통해 트리거 */} f.uploaded).map(({ storageKey }) => storageKey), + files: state.files + .filter((f) => f.uploaded) + .map((f) => ({ + fileName: f.fileName, + storageKey: f.storageKey, + fileSize: f.fileSize, + contentType: f.contentType, + })), }; }, })), From 81598b623acedcb86cbf82c26f95bb35dd9e5698 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 3 Apr 2026 17:31:22 +0900 Subject: [PATCH 03/43] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D/=EC=83=81=EC=84=B8=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-nav)/[id]/PostDetailContent.tsx | 1 + src/app/globals.css | 20 +++++--- .../board/PostCard/PostCardContent.tsx | 51 ++++++++++++++----- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx index 25a9804e..03e9cf5f 100644 --- a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx @@ -54,6 +54,7 @@ function PostDetailContent({ post }: PostDetailContentProps) { diff --git a/src/app/globals.css b/src/app/globals.css index 92f7dc87..5472e6a1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -523,11 +523,11 @@ select:focus { } .ProseMirror { - @apply text-text-strong min-h-[400px] focus:outline-none; + @apply text-text-strong focus:outline-none; } -.ProseMirror > * { - @apply my-100; +.ProseMirror:not(.prose-readonly) { + @apply min-h-[400px]; } /* placeholder */ @@ -541,23 +541,23 @@ select:focus { } .ProseMirror h1 { - @apply typo-h3 mt-300; + @apply typo-h3 mt-300 mb-100; } .ProseMirror h2 { - @apply typo-sub1 mt-300; + @apply typo-sub1 mt-300 mb-100; } .ProseMirror h3 { - @apply typo-sub2 mt-300; + @apply typo-sub2 mt-300 mb-100; } .ProseMirror ul { - @apply text-text-alternative my-0 list-disc py-0 pl-500; + @apply text-text-alternative my-0 list-disc py-100 pl-500; } .ProseMirror ol { - @apply text-text-alternative my-0 list-decimal py-0 pl-500; + @apply text-text-alternative my-0 list-decimal py-100 pl-500; } .ProseMirror ul li, @@ -625,6 +625,10 @@ select:focus { } /* 인라인 코드 */ +.ProseMirror p:has(> code:not(pre code)) { + @apply py-100; +} + .ProseMirror code:not(pre code) { background-color: var(--container-code); color: var(--text-normal); diff --git a/src/components/board/PostCard/PostCardContent.tsx b/src/components/board/PostCard/PostCardContent.tsx index 83242a14..1d2e6d71 100644 --- a/src/components/board/PostCard/PostCardContent.tsx +++ b/src/components/board/PostCard/PostCardContent.tsx @@ -22,7 +22,7 @@ function PostCardContent({ expandable = true, variant = 'list', }: PostCardContentProps) { - const contentRef = useRef(null); + const contentRef = useRef(null); const [isClamped, setIsClamped] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -31,7 +31,14 @@ function PostCardContent({ if (!el || !expandable) return; const check = () => { - setIsClamped(el.scrollHeight > el.clientHeight); + const prevDisplay = el.style.display; + const prevClamp = el.style.webkitLineClamp; + el.style.display = 'block'; + el.style.webkitLineClamp = 'unset'; + const fullHeight = el.scrollHeight; + el.style.display = prevDisplay; + el.style.webkitLineClamp = prevClamp; + setIsClamped(fullHeight > el.clientHeight); }; check(); @@ -41,6 +48,16 @@ function PostCardContent({ return () => ro.disconnect(); }, [content, expandable]); + const plainContent = + variant === 'list' + ? content + .replace(//gi, '\n') + .replace(/<\/(p|h[1-6]|li|div|blockquote)>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() + : content; + return (
@@ -54,16 +71,26 @@ function PostCardContent({ )}
-

- {content} -

+ {variant === 'list' ? ( +

+ {plainContent} +

+ ) : ( +
+ )} {expandable && isClamped && !isExpanded && (
{isMain && (
- {isWritePage ? ( + {isPostingPage ? ( <> ) : ( diff --git a/src/hooks/board/useUpdatePost.ts b/src/hooks/board/useUpdatePost.ts new file mode 100644 index 00000000..8ed9f5a7 --- /dev/null +++ b/src/hooks/board/useUpdatePost.ts @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { updatePost } from '@/lib/actions/board'; +import { useClubId } from '@/stores/useClubStore'; +import { usePostStore } from '@/stores/usePostStore'; +import { toast } from '@/stores/useToastStore'; + +/** + * 게시글 수정 Server Action 호출 훅 + * + * - usePostStore에서 title, content, files를 꺼내 updatePost Server Action 호출 + * - 성공 시 store reset + 게시글 상세로 이동 + * - 업로드 미완료 파일이 있으면 제출 차단 + */ +export function useUpdatePost() { + const router = useRouter(); + const clubId = useClubId(); + const [isPending, setIsPending] = useState(false); + + const submit = async (postId: number) => { + const { title, content, files, getPayload, reset } = usePostStore.getState(); + + if (!clubId) { + toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); + return; + } + + if (!title.trim()) { + toast({ title: '제목을 입력해주세요.', variant: 'error' }); + return; + } + + if (!content.trim()) { + toast({ title: '내용을 입력해주세요.', variant: 'error' }); + return; + } + + const uploading = files.some((f) => !f.uploaded && f.file); + if (uploading) { + toast({ title: '파일 업로드가 진행 중입니다.', variant: 'error' }); + return; + } + + setIsPending(true); + try { + await updatePost(clubId, postId, getPayload()); + reset(); + router.push(`/board/${postId}`); + } catch { + toast({ title: '게시글 수정에 실패했습니다.', variant: 'error' }); + } finally { + setIsPending(false); + } + }; + + return { submit, isPending }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 838b80b1..54d6bd81 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,5 +9,6 @@ export { useRemainingTime } from './useRemainingTime'; export { useScrollOnGrow } from './useScrollOnGrow'; export { useBoardList, useBoardPosts } from './board/useBoardQuery'; export { useCreatePost } from './board/useCreatePost'; +export { useUpdatePost } from './board/useUpdatePost'; export { useIntersectionObserver } from './board/useIntersectionObserver'; export { useProgressAnimation } from './useProgressAnimation'; diff --git a/src/lib/actions/board.ts b/src/lib/actions/board.ts index 1abfd5f7..6eff2c3e 100644 --- a/src/lib/actions/board.ts +++ b/src/lib/actions/board.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; import { boardServerApi } from '@/lib/apis/board.server'; -import type { CreatePostBody } from '@/types/board'; +import type { CreatePostBody, UpdatePostBody } from '@/types/board'; export async function readAllNotices(clubId: string, boardId: number) { await boardServerApi.readAllNotices(clubId, boardId); @@ -12,6 +12,12 @@ export async function readAllNotices(clubId: string, boardId: number) { export async function createPost(clubId: string, boardId: number, body: CreatePostBody) { const response = await boardServerApi.createPost(clubId, boardId, body); revalidatePath('/board', 'layout'); - console.log('Post created:', response.data); + return response.data; +} + +export async function updatePost(clubId: string, postId: number, body: UpdatePostBody) { + const response = await boardServerApi.updatePost(clubId, postId, body); + revalidatePath('/board', 'layout'); + revalidatePath(`/board/${postId}`); return response.data; } diff --git a/src/lib/apis/board.server.ts b/src/lib/apis/board.server.ts index 69e39f9f..8d949c09 100644 --- a/src/lib/apis/board.server.ts +++ b/src/lib/apis/board.server.ts @@ -1,6 +1,13 @@ import { apiServer } from '@/lib/apis/server'; import type { ApiResponse } from '@/types/common'; -import type { Board, CreatePostBody, CreatePostData, PostDetail } from '@/types/board'; +import type { + Board, + CreatePostBody, + CreatePostData, + PostDetail, + UpdatePostBody, + UpdatePostData, +} from '@/types/board'; export const boardServerApi = { /** 게시판 목록 조회 (RSC) — 거의 변하지 않으므로 30분 캐싱 */ @@ -22,4 +29,8 @@ export const boardServerApi = { /** 게시글 작성 (Server Action) */ createPost: (clubId: string, boardId: number, body: CreatePostBody) => apiServer.post>(`/clubs/${clubId}/boards/${boardId}/posts`, body), + + /** 게시글 수정 (Server Action) */ + updatePost: (clubId: string, postId: number, body: UpdatePostBody) => + apiServer.patch>(`/clubs/${clubId}/boards/posts/${postId}`, body), }; diff --git a/src/stores/usePostStore.ts b/src/stores/usePostStore.ts index 6d533401..776a6028 100644 --- a/src/stores/usePostStore.ts +++ b/src/stores/usePostStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; import { combine, devtools } from 'zustand/middleware'; +import type { PostDetail } from '@/types/board'; + export interface UploadFileItem { id: string; file?: File; @@ -80,6 +82,36 @@ export const usePostStore = create( set(initialState, false, 'reset'); }, + /** + * 기존 게시글 상세 데이터로 스토어를 초기화 (수정 페이지 진입 시 사용) + * - 내부적으로 reset을 수행한 뒤 PostDetail의 필드를 스토어 상태로 매핑 + */ + initFromDetail: (post: PostDetail) => { + const { files } = get(); + for (const f of files) { + if (f.fileUrl.startsWith('blob:')) URL.revokeObjectURL(f.fileUrl); + } + set( + { + ...initialState, + board: post.boardId, + title: post.title, + content: post.content, + files: post.fileUrls.map((f) => ({ + id: String(f.fileId), + fileName: f.fileName, + fileUrl: f.fileUrl, + storageKey: f.storageKey, + fileSize: f.fileSize, + contentType: f.contentType, + uploaded: true, + })), + }, + false, + 'initFromDetail', + ); + }, + getPayload: () => { const state = get(); return { diff --git a/src/types/board.ts b/src/types/board.ts index 58fda951..224713bd 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -103,6 +103,14 @@ export interface CreatePostData { id: number; } +/** 게시글 수정 요청 body */ +export type UpdatePostBody = CreatePostBody; + +/** 게시글 수정 응답 data */ +export interface UpdatePostData { + id: number; +} + /** mapComment 변환 결과 (UI 표시용) */ export interface MappedComment { id: number; From 4303ae2e7c96a66263c4df366afef2e1f006df00 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 5 Apr 2026 21:42:19 +0900 Subject: [PATCH 05/43] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActionMenu를 범용 수정/삭제 메뉴로 분리하고 PostActionMenu는 게시글 전용 래퍼로 재작성 - PostDeleteDialog + useDeletePost 훅 추가, 삭제 성공 시 board 경로 revalidate - 댓글/답글은 공통 ActionMenu 재사용 --- .../(with-nav)/[id]/PostDetailContent.tsx | 8 +- src/components/board/ActionMenu.tsx | 66 ++++++++++++++ src/components/board/Comment/CommentItem.tsx | 6 +- src/components/board/Comment/ReplyItem.tsx | 6 +- src/components/board/PostActionMenu.tsx | 88 ++++++++----------- src/components/board/PostDeleteDialog.tsx | 41 +++++++++ src/components/board/index.ts | 1 + src/hooks/board/useDeletePost.ts | 42 +++++++++ src/hooks/index.ts | 1 + src/lib/actions/board.ts | 5 ++ src/lib/apis/board.server.ts | 4 + 11 files changed, 210 insertions(+), 58 deletions(-) create mode 100644 src/components/board/ActionMenu.tsx create mode 100644 src/components/board/PostDeleteDialog.tsx create mode 100644 src/hooks/board/useDeletePost.ts diff --git a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx index 77e1a5a5..a1ff9b7e 100644 --- a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx @@ -50,7 +50,13 @@ function PostDetailContent({ post }: PostDetailContentProps) { date={formatShortDateTime(post.time)} hasAttachment={post.fileUrls.length > 0} /> - {isPostAuthor && router.push(`/board/edit/${post.id}`)} />} + {isPostAuthor && ( + router.push(`/board/edit/${post.id}`)} + onDeleted={() => router.push('/board')} + /> + )} void; + onDeleteSelect?: (event: Event) => void; + onClick?: React.MouseEventHandler; + triggerVariant?: ButtonProps['variant']; + triggerSize?: ButtonProps['size']; + triggerClassName?: string; +} + +/** + * 수정/삭제 드롭다운 메뉴 + * + * 외부 핸들러에 동작을 위임합니다. post 삭제처럼 확인 다이얼로그 + API 호출이 + * 내장된 동작이 필요하면 `PostActionMenu`를 사용하세요. + */ +function ActionMenu({ + className, + onEdit, + onDeleteSelect, + onClick, + triggerVariant = 'tertiary', + triggerSize = 'icon-md', + triggerClassName, +}: ActionMenuProps) { + return ( + + + + + + 수정 + + + 삭제 + + + + ); +} + +export { ActionMenu, type ActionMenuProps }; diff --git a/src/components/board/Comment/CommentItem.tsx b/src/components/board/Comment/CommentItem.tsx index 03aee7ab..c0ea231b 100644 --- a/src/components/board/Comment/CommentItem.tsx +++ b/src/components/board/Comment/CommentItem.tsx @@ -5,7 +5,7 @@ import { ChatIcon } from '@/assets/icons'; import { Avatar, AvatarFallback, AvatarImage, Button, Icon } from '@/components/ui'; import { useScrollIntoView } from '@/hooks'; import { cn } from '@/lib/cn'; -import { PostActionMenu } from '@/components/board/PostActionMenu'; +import { ActionMenu } from '@/components/board/ActionMenu'; import type { UploadFileItem } from '@/stores/usePostStore'; import { CommentInput } from './CommentInput'; import { ReplyItem, type ReplyItemProps } from './ReplyItem'; @@ -69,11 +69,11 @@ function CommentItem({ {isAuthor && ( - )}
diff --git a/src/components/board/Comment/ReplyItem.tsx b/src/components/board/Comment/ReplyItem.tsx index cea65e64..e4961670 100644 --- a/src/components/board/Comment/ReplyItem.tsx +++ b/src/components/board/Comment/ReplyItem.tsx @@ -3,7 +3,7 @@ import { ReplyIcon } from '@/assets/icons'; import { Avatar, AvatarFallback, AvatarImage, Icon } from '@/components/ui'; import { cn } from '@/lib/cn'; -import { PostActionMenu } from '@/components/board/PostActionMenu'; +import { ActionMenu } from '@/components/board/ActionMenu'; interface ReplyItemProps { id: number | string; @@ -43,11 +43,11 @@ function ReplyItem({

{date}

{isAuthor && ( - )}
diff --git a/src/components/board/PostActionMenu.tsx b/src/components/board/PostActionMenu.tsx index 9fc07ffa..777f3720 100644 --- a/src/components/board/PostActionMenu.tsx +++ b/src/components/board/PostActionMenu.tsx @@ -1,59 +1,45 @@ 'use client'; -import { MoreVerticalIcon } from '@/assets/icons'; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Icon, -} from '@/components/ui'; -import { cn } from '@/lib/cn'; -import type { ButtonProps } from '@/components/ui'; - -interface PostActionMenuProps { - className?: string; - onEdit?: () => void; - onDelete?: () => void; - onClick?: React.MouseEventHandler; - triggerVariant?: ButtonProps['variant']; - triggerSize?: ButtonProps['size']; - triggerClassName?: string; +import { useState } from 'react'; +import dynamic from 'next/dynamic'; +import { ActionMenu, type ActionMenuProps } from './ActionMenu'; + +const PostDeleteDialog = dynamic(() => + import('./PostDeleteDialog').then((mod) => mod.PostDeleteDialog), +); + +interface PostActionMenuProps extends Omit { + postId: number; + onDeleted?: () => void; } -function PostActionMenu({ - className, - onEdit, - onDelete, - onClick, - triggerVariant = 'tertiary', - triggerSize = 'icon-md', - triggerClassName, -}: PostActionMenuProps) { +/** + * 게시글 전용 수정/삭제 메뉴 + * + * 삭제 선택 시 확인 다이얼로그를 띄우고 내부에서 deletePost API를 호출 + * 댓글/답글 등 범용 용도에는 `ActionMenu`를 사용 + */ +function PostActionMenu({ postId, onDeleted, ...rest }: PostActionMenuProps) { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const handleDeleteSelect = (event: Event) => { + event.preventDefault(); + setDeleteDialogOpen(true); + }; + return ( - - - - - - 수정 - - - 삭제 - - - + <> + + + {deleteDialogOpen ? ( + + ) : null} + ); } diff --git a/src/components/board/PostDeleteDialog.tsx b/src/components/board/PostDeleteDialog.tsx new file mode 100644 index 00000000..12a9bc1b --- /dev/null +++ b/src/components/board/PostDeleteDialog.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { AlertDialog, AlertDialogAction, AlertDialogCancel } from '@/components/ui'; +import { useDeletePost } from '@/hooks'; + +interface PostDeleteDialogProps { + postId: number; + open: boolean; + onOpenChange: (open: boolean) => void; + onDeleted?: () => void; +} + +/** + * 게시글 삭제 확인 다이얼로그 + */ +function PostDeleteDialog({ postId, open, onOpenChange, onDeleted }: PostDeleteDialogProps) { + const { submit, isPending } = useDeletePost(); + + const handleConfirm = async (event: React.MouseEvent) => { + event.preventDefault(); + await submit(postId, onDeleted); + onOpenChange(false); + }; + + return ( + + + 삭제 + + 취소 + + ); +} + +export { PostDeleteDialog, type PostDeleteDialogProps }; diff --git a/src/components/board/index.ts b/src/components/board/index.ts index 05f2770f..80228fb9 100644 --- a/src/components/board/index.ts +++ b/src/components/board/index.ts @@ -9,6 +9,7 @@ export { PostCard } from './PostCard'; export { CommentInput, type CommentInputProps } from './Comment'; export { CommentItem, type CommentItemProps } from './Comment'; export { ReplyItem, type ReplyItemProps } from './Comment'; +export { ActionMenu, type ActionMenuProps } from './ActionMenu'; export { PostActionMenu, type PostActionMenuProps } from './PostActionMenu'; export { PostDetailHeader, type PostDetailHeaderProps } from './PostDetailHeader'; export { FileList, type FileListProps } from './FileList'; diff --git a/src/hooks/board/useDeletePost.ts b/src/hooks/board/useDeletePost.ts new file mode 100644 index 00000000..d188dc08 --- /dev/null +++ b/src/hooks/board/useDeletePost.ts @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { deletePost } from '@/lib/actions/board'; +import { useClubId } from '@/stores/useClubStore'; +import { toast } from '@/stores/useToastStore'; + +/** + * 게시글 삭제 Server Action 호출 훅 + * + * - clubId(store) + postId(인자)로 deletePost Server Action 호출 + * - 성공 시 게시글 목록/홈 최근 게시글 React Query 캐시 무효화 + * - 성공/실패 토스트 노출 + */ +export function useDeletePost() { + const clubId = useClubId(); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + + const submit = async (postId: number, onSuccess?: () => void) => { + if (!clubId) { + toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); + return; + } + + setIsPending(true); + try { + await deletePost(clubId, postId); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['posts'] }), + queryClient.invalidateQueries({ queryKey: ['home', 'recent-posts'] }), + ]); + toast({ title: '게시글이 삭제되었습니다.', variant: 'success' }); + onSuccess?.(); + } catch { + toast({ title: '게시글 삭제에 실패했습니다.', variant: 'error' }); + } finally { + setIsPending(false); + } + }; + + return { submit, isPending }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 54d6bd81..36b5f7ce 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -10,5 +10,6 @@ export { useScrollOnGrow } from './useScrollOnGrow'; export { useBoardList, useBoardPosts } from './board/useBoardQuery'; export { useCreatePost } from './board/useCreatePost'; export { useUpdatePost } from './board/useUpdatePost'; +export { useDeletePost } from './board/useDeletePost'; export { useIntersectionObserver } from './board/useIntersectionObserver'; export { useProgressAnimation } from './useProgressAnimation'; diff --git a/src/lib/actions/board.ts b/src/lib/actions/board.ts index 6eff2c3e..83060320 100644 --- a/src/lib/actions/board.ts +++ b/src/lib/actions/board.ts @@ -21,3 +21,8 @@ export async function updatePost(clubId: string, postId: number, body: UpdatePos revalidatePath(`/board/${postId}`); return response.data; } + +export async function deletePost(clubId: string, postId: number) { + await boardServerApi.deletePost(clubId, postId); + revalidatePath('/board', 'layout'); +} diff --git a/src/lib/apis/board.server.ts b/src/lib/apis/board.server.ts index 8d949c09..a2e452d7 100644 --- a/src/lib/apis/board.server.ts +++ b/src/lib/apis/board.server.ts @@ -33,4 +33,8 @@ export const boardServerApi = { /** 게시글 수정 (Server Action) */ updatePost: (clubId: string, postId: number, body: UpdatePostBody) => apiServer.patch>(`/clubs/${clubId}/boards/posts/${postId}`, body), + + /** 게시글 삭제 (Server Action) */ + deletePost: (clubId: string, postId: number) => + apiServer.delete>(`/clubs/${clubId}/boards/posts/${postId}`), }; From 09cf581d0b4b42d4deab7d314480efcca6d5238f Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 5 Apr 2026 21:43:11 +0900 Subject: [PATCH 06/43] =?UTF-8?q?refactor:=20PostCardContent=EB=A5=BC=20Li?= =?UTF-8?q?stContent=20/=20DetailContent=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-nav)/[id]/PostDetailContent.tsx | 8 +- src/components/board/BoardContent.tsx | 4 +- .../board/PostCard/PostCardContent.tsx | 193 ++++++++++++------ src/components/board/PostCard/index.tsx | 5 +- src/components/home/HomeBoardContent.tsx | 6 +- 5 files changed, 135 insertions(+), 81 deletions(-) diff --git a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx index a1ff9b7e..a6030b79 100644 --- a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx @@ -59,13 +59,7 @@ function PostDetailContent({ post }: PostDetailContentProps) { )} - + diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 20e18e51..30901c05 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -57,10 +57,10 @@ function BoardContent() { hasAttachment={post.hasFile} /> {currentUserId === post.author.id && ( - e.preventDefault()} /> + e.preventDefault()} /> )} - + diff --git a/src/components/board/PostCard/PostCardContent.tsx b/src/components/board/PostCard/PostCardContent.tsx index 1d2e6d71..640991b0 100644 --- a/src/components/board/PostCard/PostCardContent.tsx +++ b/src/components/board/PostCard/PostCardContent.tsx @@ -5,30 +5,36 @@ import Image from 'next/image'; import { NewIcon } from '@/assets/icons'; import { cn } from '@/lib/cn'; -interface PostCardContentProps { - className?: string; +interface PostCardTitleProps { title: string; - content: string; isNew?: boolean; - expandable?: boolean; - variant?: 'list' | 'detail'; + size: 'list' | 'detail'; } -function PostCardContent({ - className, - title, - content, - isNew, - expandable = true, - variant = 'list', -}: PostCardContentProps) { - const contentRef = useRef(null); +function PostCardTitle({ title, isNew, size }: PostCardTitleProps) { + return ( +
+

+ {title} +

+ {isNew && ( + <> + + 새 글 + + )} +
+ ); +} + +function useLineClamp(enabled: boolean, deps: unknown[]) { + const ref = useRef(null); const [isClamped, setIsClamped] = useState(false); const [isExpanded, setIsExpanded] = useState(false); useEffect(() => { - const el = contentRef.current; - if (!el || !expandable) return; + const el = ref.current; + if (!el || !enabled) return; const check = () => { const prevDisplay = el.style.display; @@ -46,66 +52,117 @@ function PostCardContent({ const ro = new ResizeObserver(check); ro.observe(el); return () => ro.disconnect(); - }, [content, expandable]); - - const plainContent = - variant === 'list' - ? content - .replace(//gi, '\n') - .replace(/<\/(p|h[1-6]|li|div|blockquote)>/gi, '\n') - .replace(/<[^>]*>/g, '') - .replace(/\n{3,}/g, '\n\n') - .trim() - : content; + }, [enabled, ...deps]); + + return { ref, isClamped, isExpanded, setIsExpanded }; +} + +interface ExpandButtonProps { + onExpand: () => void; +} + +function ExpandButton({ onExpand }: ExpandButtonProps) { + return ( + + ); +} + +interface PostCardListContentProps { + className?: string; + title: string; + content: string; + isNew?: boolean; + expandable?: boolean; +} + +function PostCardListContent({ + className, + title, + content, + isNew, + expandable = true, +}: PostCardListContentProps) { + const { ref, isClamped, isExpanded, setIsExpanded } = useLineClamp( + expandable, + [content], + ); + + const plainContent = content + .replace(//gi, '\n') + .replace(/<\/(p|h[1-6]|li|div|blockquote)>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); return (
-
-

- {title} -

- {isNew && ( - <> - - 새 글 - + +

- {variant === 'list' ? ( -

- {plainContent} -

- ) : ( -
+ > + {plainContent} +

+ {expandable && isClamped && !isExpanded && ( + setIsExpanded(true)} /> )} +
+ ); +} + +interface PostCardDetailContentProps { + className?: string; + title: string; + content: string; + isNew?: boolean; + expandable?: boolean; +} + +function PostCardDetailContent({ + className, + title, + content, + isNew, + expandable = false, +}: PostCardDetailContentProps) { + const { ref, isClamped, isExpanded, setIsExpanded } = useLineClamp(expandable, [ + content, + ]); + + return ( +
+ +
{expandable && isClamped && !isExpanded && ( - + setIsExpanded(true)} /> )}
); } -export { PostCardContent, type PostCardContentProps }; +export { + PostCardListContent, + PostCardDetailContent, + type PostCardListContentProps, + type PostCardDetailContentProps, +}; diff --git a/src/components/board/PostCard/index.tsx b/src/components/board/PostCard/index.tsx index be8a2c53..5358eef8 100644 --- a/src/components/board/PostCard/index.tsx +++ b/src/components/board/PostCard/index.tsx @@ -2,7 +2,7 @@ import { cn } from '@/lib/cn'; import { ImageList } from '@/components/board/ImageList'; import type { DisplayFile } from '@/types/board'; import { PostAuthorInfo } from './PostAuthorInfo'; -import { PostCardContent } from './PostCardContent'; +import { PostCardListContent, PostCardDetailContent } from './PostCardContent'; import { PostCardActions, type PostCardActionsProps } from './PostCardActions'; function PostCardRoot({ className, children, ...props }: React.ComponentProps<'article'>) { @@ -45,7 +45,8 @@ const PostCard = { Root: PostCardRoot, Header: PostCardHeader, Author: PostAuthorInfo, - Content: PostCardContent, + ListContent: PostCardListContent, + DetailContent: PostCardDetailContent, Images: PostCardImages, Actions: PostCardActions, }; diff --git a/src/components/home/HomeBoardContent.tsx b/src/components/home/HomeBoardContent.tsx index c2561f66..0e48fc94 100644 --- a/src/components/home/HomeBoardContent.tsx +++ b/src/components/home/HomeBoardContent.tsx @@ -34,9 +34,11 @@ function HomeBoardContent() { dateTime={post.time} hasAttachment={hasAttachment} /> - {isMyPost && e.preventDefault()} />} + {isMyPost && ( + e.preventDefault()} /> + )} - + From dc723f5a1e109fae8a0358c3dadc03eb0e1dcfed Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 5 Apr 2026 21:43:35 +0900 Subject: [PATCH 07/43] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20CategorySelector=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/edit/[id]/EditClientEditor.tsx | 50 ++++++------------- src/components/board/PostEditorShell.tsx | 17 ++++--- 2 files changed, 24 insertions(+), 43 deletions(-) diff --git a/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx b/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx index eacf4223..93f5cc5a 100644 --- a/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx +++ b/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx @@ -1,17 +1,10 @@ 'use client'; import { useRef } from 'react'; -import Link from 'next/link'; - -import { PostEditorShell } from '@/components/board'; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from '@/components/ui'; + +import { CategorySelector, PostEditorShell } from '@/components/board'; +import { useBoardList } from '@/hooks'; +import { toBoardNavItem } from '@/lib/board'; import { usePostStore } from '@/stores/usePostStore'; import type { PostDetail } from '@/types/board'; @@ -26,34 +19,19 @@ function EditClientEditor({ post }: EditClientEditorProps) { usePostStore.getState().initFromDetail(post); } + const { data: boards } = useBoardList(); + const items = boards?.map(toBoardNavItem) ?? []; + + const board = usePostStore((s) => s.board); + const setBoard = usePostStore((s) => s.setBoard); + + const activeId = board ?? post.boardId; + return ( - - - - 게시판 - - - - - {post.boardName} - - - - - - {post.title} - - - - - 수정 - - - } + header={} /> ); } diff --git a/src/components/board/PostEditorShell.tsx b/src/components/board/PostEditorShell.tsx index a733fca3..732f5165 100644 --- a/src/components/board/PostEditorShell.tsx +++ b/src/components/board/PostEditorShell.tsx @@ -10,14 +10,20 @@ import { usePostStore } from '@/stores/usePostStore'; const Editor = dynamic(() => import('./Editor'), { ssr: false }); interface PostEditorShellProps { - /** 상단 영역 (글쓰기: CategorySelector, 수정: Breadcrumb 등) */ header: ReactNode; - /** 수정 페이지에서 Editor에 주입할 초기 콘텐츠 */ initialContent?: string; - /** 외곽 컨테이너의 cross-axis 정렬 (기본값: start) */ align?: 'start' | 'center'; } +/** + * title 구독을 shell 밖으로 격리 + */ +function BoundTitleInput() { + const title = usePostStore((s) => s.title); + const setTitle = usePostStore((s) => s.setTitle); + return setTitle(e.target.value)} />; +} + /** * 게시글 작성/수정 페이지 공통 레이아웃 Shell * @@ -26,9 +32,6 @@ interface PostEditorShellProps { * - Store 초기화는 사용하는 쪽에서 책임 (write: reset, edit: 기존 데이터 복원) */ function PostEditorShell({ header, initialContent, align = 'start' }: PostEditorShellProps) { - const title = usePostStore((s) => s.title); - const setTitle = usePostStore((s) => s.setTitle); - return (
{header}
- setTitle(e.target.value)} /> +
From 714c71ba3a4c786ecf2923315d77f9c3b07de947 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 15:23:57 +0900 Subject: [PATCH 08/43] =?UTF-8?q?fix:=20ActionMenu=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=8B=9C=20Link=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/ActionMenu.tsx | 35 ++++++++++++++---------- src/components/board/BoardContent.tsx | 2 +- src/components/home/HomeBoardContent.tsx | 2 +- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/components/board/ActionMenu.tsx b/src/components/board/ActionMenu.tsx index 7ac76770..b0dd1e72 100644 --- a/src/components/board/ActionMenu.tsx +++ b/src/components/board/ActionMenu.tsx @@ -17,7 +17,6 @@ interface ActionMenuProps { className?: string; onEdit?: () => void; onDeleteSelect?: (event: Event) => void; - onClick?: React.MouseEventHandler; triggerVariant?: ButtonProps['variant']; triggerSize?: ButtonProps['size']; triggerClassName?: string; @@ -33,25 +32,31 @@ function ActionMenu({ className, onEdit, onDeleteSelect, - onClick, triggerVariant = 'tertiary', triggerSize = 'icon-md', triggerClassName, }: ActionMenuProps) { return ( - - - - + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + 수정 diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 30901c05..8e993b8f 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -57,7 +57,7 @@ function BoardContent() { hasAttachment={post.hasFile} /> {currentUserId === post.author.id && ( - e.preventDefault()} /> + )} diff --git a/src/components/home/HomeBoardContent.tsx b/src/components/home/HomeBoardContent.tsx index 0e48fc94..b1e2ffe6 100644 --- a/src/components/home/HomeBoardContent.tsx +++ b/src/components/home/HomeBoardContent.tsx @@ -35,7 +35,7 @@ function HomeBoardContent() { hasAttachment={hasAttachment} /> {isMyPost && ( - e.preventDefault()} /> + )} From 9c1d74abc7577041ad534ae497dd45ef57a70ca5 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 15:25:28 +0900 Subject: [PATCH 09/43] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1/=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=9D=B4=ED=83=88=20=EB=B0=A9=EC=A7=80=20guard=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/PostEditorShell.tsx | 22 ++++++++ src/hooks/index.ts | 1 + src/hooks/useNavigationGuard.ts | 71 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 src/hooks/useNavigationGuard.ts diff --git a/src/components/board/PostEditorShell.tsx b/src/components/board/PostEditorShell.tsx index 732f5165..a754fd98 100644 --- a/src/components/board/PostEditorShell.tsx +++ b/src/components/board/PostEditorShell.tsx @@ -4,6 +4,8 @@ import dynamic from 'next/dynamic'; import type { ReactNode } from 'react'; import { TitleInput } from './TitleInput'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel } from '@/components/ui'; +import { useNavigationGuard } from '@/hooks'; import { cn } from '@/lib/cn'; import { usePostStore } from '@/stores/usePostStore'; @@ -30,8 +32,16 @@ function BoundTitleInput() { * - 외곽 레이아웃 + TitleInput + Editor 렌더링을 담당 * - 상단 영역(header)과 Editor 초기 콘텐츠는 주입 * - Store 초기화는 사용하는 쪽에서 책임 (write: reset, edit: 기존 데이터 복원) + * - 브라우저 뒤로가기 / 탭 닫기 시 navigation guard 제공 */ function PostEditorShell({ header, initialContent, align = 'start' }: PostEditorShellProps) { + const title = usePostStore((s) => s.title); + const content = usePostStore((s) => s.content); + const files = usePostStore((s) => s.files); + + const hasChanges = title.length > 0 || content.length > 0 || files.length > 0; + const { open, onConfirm, onCancel } = useNavigationGuard({ enabled: hasChanges }); + return (
+ + { + if (!isOpen) onCancel(); + }} + title="변경 사항이 저장되지 않았어요" + description={'지금 나가면 작성 중인 내용이 사라집니다.\n계속하시겠어요?'} + > + 나가기 + 계속 작성 +
); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 36b5f7ce..78eb771e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -12,4 +12,5 @@ export { useCreatePost } from './board/useCreatePost'; export { useUpdatePost } from './board/useUpdatePost'; export { useDeletePost } from './board/useDeletePost'; export { useIntersectionObserver } from './board/useIntersectionObserver'; +export { useNavigationGuard } from './useNavigationGuard'; export { useProgressAnimation } from './useProgressAnimation'; diff --git a/src/hooks/useNavigationGuard.ts b/src/hooks/useNavigationGuard.ts new file mode 100644 index 00000000..5dc52d6d --- /dev/null +++ b/src/hooks/useNavigationGuard.ts @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface UseNavigationGuardOptions { + enabled: boolean; +} + +const GUARD_STATE = { __navigationGuard: true } as const; + +function isGuardEntry() { + return !!(history.state as Record | null)?.__navigationGuard; +} + +/** + * 브라우저 뒤로가기(popstate) 및 탭 닫기/새로고침(beforeunload)을 + * 가로채서 사용자에게 확인을 요청하는 훅. + * + * 반환값의 open / onConfirm / onCancel 을 AlertDialog에 바인딩하여 사용. + */ +function useNavigationGuard({ enabled }: UseNavigationGuardOptions) { + const [open, setOpen] = useState(false); + const isLeaving = useRef(false); + const guardUrl = useRef(''); + + useEffect(() => { + if (!enabled) return; + + guardUrl.current = location.href; + + if (!isGuardEntry()) { + history.pushState(GUARD_STATE, '', location.href); + } + + const handlePopState = () => { + if (isLeaving.current) return; + setOpen(true); + }; + + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + + window.addEventListener('popstate', handlePopState); + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('popstate', handlePopState); + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [enabled]); + + const onConfirm = () => { + isLeaving.current = true; + setOpen(false); + history.back(); + }; + + const onCancel = () => { + if (isLeaving.current) { + isLeaving.current = false; + return; + } + setOpen(false); + history.pushState(GUARD_STATE, '', guardUrl.current); + }; + + return { open, onConfirm, onCancel }; +} + +export { useNavigationGuard }; From 2ae1bc0826fd858df82762fedf395e3a6827b817 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 15:26:44 +0900 Subject: [PATCH 10/43] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/board/(with-nav)/BoardNavClient.tsx | 14 +++++++++++++- .../board/(with-nav)/[id]/PostDetailContent.tsx | 7 +++++++ src/components/board/PostDetailHeader.tsx | 2 +- src/components/layout/header/Header.tsx | 4 ++-- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/app/(private)/(main)/board/(with-nav)/BoardNavClient.tsx b/src/app/(private)/(main)/board/(with-nav)/BoardNavClient.tsx index 63bc3d24..ce2d8464 100644 --- a/src/app/(private)/(main)/board/(with-nav)/BoardNavClient.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/BoardNavClient.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; import { BoardNav } from '@/components/board'; import { useActiveBoardId, useSetActiveBoardId } from '@/stores/useBoardNavStore'; @@ -13,6 +14,10 @@ interface BoardNavClientProps { function BoardNavClient({ items }: BoardNavClientProps) { const activeBoardId = useActiveBoardId(); const setActiveBoardId = useSetActiveBoardId(); + const pathname = usePathname(); + const router = useRouter(); + + const isDetailPage = /^\/board\/\d+$/.test(pathname); useEffect(() => { if (activeBoardId === null) return; @@ -22,7 +27,14 @@ function BoardNavClient({ items }: BoardNavClientProps) { } }, [items, activeBoardId, setActiveBoardId]); - return ; + const handleItemSelect = (id: number | null) => { + setActiveBoardId(id); + if (isDetailPage) { + router.push('/board'); + } + }; + + return ; } export { BoardNavClient }; diff --git a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx index a6030b79..adcfdf8d 100644 --- a/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/[id]/PostDetailContent.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Divider } from '@/components/ui'; import { @@ -12,6 +13,7 @@ import { } from '@/components/board'; import { formatShortDateTime } from '@/lib/formatTime'; import { toDisplayFile, isImageFileByType, mapComment } from '@/lib/board'; +import { useSetActiveBoardId } from '@/stores/useBoardNavStore'; import { useUserId } from '@/stores/useUserStore'; import type { PostDetail } from '@/types/board'; import type { UploadFileItem } from '@/stores/usePostStore'; @@ -23,6 +25,11 @@ interface PostDetailContentProps { function PostDetailContent({ post }: PostDetailContentProps) { const router = useRouter(); const currentUserId = useUserId(); + const setActiveBoardId = useSetActiveBoardId(); + + useEffect(() => { + setActiveBoardId(post.boardId); + }, [post.boardId, setActiveBoardId]); const isPostAuthor = currentUserId !== null && post.author.id === currentUserId; const imageFiles = post.fileUrls diff --git a/src/components/board/PostDetailHeader.tsx b/src/components/board/PostDetailHeader.tsx index d1806c0f..0d2a4a88 100644 --- a/src/components/board/PostDetailHeader.tsx +++ b/src/components/board/PostDetailHeader.tsx @@ -19,7 +19,7 @@ function PostDetailHeader({ className }: PostDetailHeaderProps) { variant="tertiary" size="icon-md" className="h-600 w-600" - onClick={() => router.back()} + onClick={() => router.push('/board')} aria-label="뒤로 가기" > diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 67fe75bf..775b025f 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -4,7 +4,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { usePathname } from 'next/navigation'; -import { Button, Icon } from '../../ui'; +import { Button, Icon } from '@/components/ui'; import { MenuIcon, EditIcon, SendIcon, ExitToAppIcon, AvatarIcon, LogoIcon } from '@/assets/icons'; import { useCreatePost, useUpdatePost } from '@/hooks'; @@ -100,8 +100,8 @@ export default function Header({ isMain = true }: HeaderProps) { From 997c01676ebd5d9b10f8027fe31659510e5096ba Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 15:27:02 +0900 Subject: [PATCH 11/43] =?UTF-8?q?style:=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=EC=98=81=EC=97=AD=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/globals.css | 2 +- src/components/board/Editor/index.tsx | 12 ++++++------ src/stores/usePostStore.ts | 3 +++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 5472e6a1..0928be80 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -527,7 +527,7 @@ select:focus { } .ProseMirror:not(.prose-readonly) { - @apply min-h-[400px]; + @apply flex-1; } /* placeholder */ diff --git a/src/components/board/Editor/index.tsx b/src/components/board/Editor/index.tsx index 86d89aae..c05c1b68 100644 --- a/src/components/board/Editor/index.tsx +++ b/src/components/board/Editor/index.tsx @@ -60,7 +60,7 @@ export default function Editor({ initialContent }: EditorProps = {}) { if (!editor) return null; return ( -
+
{/* 숨겨진 파일 input — 슬래시 메뉴에서 각 ref를 통해 트리거 */} +
- {/* 게시글 하단 첨부 영역 */} -
- - -
+ {/* 게시글 하단 첨부 영역 */} +
+ +
); diff --git a/src/stores/usePostStore.ts b/src/stores/usePostStore.ts index 776a6028..deac248d 100644 --- a/src/stores/usePostStore.ts +++ b/src/stores/usePostStore.ts @@ -12,6 +12,8 @@ export interface UploadFileItem { fileSize: number; contentType: string; uploaded: boolean; + /** 서버에서 불러온 기존 파일인지 여부 (수정 시 재전송 방지) */ + isExisting?: boolean; } const initialState = { @@ -105,6 +107,7 @@ export const usePostStore = create( fileSize: f.fileSize, contentType: f.contentType, uploaded: true, + isExisting: true, })), }, false, From 9e95ac5e6aab345f9b946f2fcf9c109872b9a806 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 15:29:42 +0900 Subject: [PATCH 12/43] =?UTF-8?q?fix:=20prettier=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoardContent.tsx | 4 +--- src/components/home/HomeBoardContent.tsx | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 8e993b8f..970cb501 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -56,9 +56,7 @@ function BoardContent() { date={formatShortDateTime(post.time)} hasAttachment={post.hasFile} /> - {currentUserId === post.author.id && ( - - )} + {currentUserId === post.author.id && } diff --git a/src/components/home/HomeBoardContent.tsx b/src/components/home/HomeBoardContent.tsx index b1e2ffe6..739e0120 100644 --- a/src/components/home/HomeBoardContent.tsx +++ b/src/components/home/HomeBoardContent.tsx @@ -34,9 +34,7 @@ function HomeBoardContent() { dateTime={post.time} hasAttachment={hasAttachment} /> - {isMyPost && ( - - )} + {isMyPost && } From db30143916202f62746e4622607fa9bd8e513f08 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 16:31:11 +0900 Subject: [PATCH 13/43] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1/=EC=88=98=EC=A0=95=20=ED=9B=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1=EC=8B=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=84=B1=EA=B3=B5=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/board/useCreatePost.ts | 4 ++++ src/hooks/board/useUpdatePost.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/hooks/board/useCreatePost.ts b/src/hooks/board/useCreatePost.ts index 2ddbcd64..3f20a726 100644 --- a/src/hooks/board/useCreatePost.ts +++ b/src/hooks/board/useCreatePost.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; import { createPost } from '@/lib/actions/board'; import { useClubId } from '@/stores/useClubStore'; import { usePostStore } from '@/stores/usePostStore'; @@ -15,6 +16,7 @@ import { toast } from '@/stores/useToastStore'; export function useCreatePost() { const router = useRouter(); const clubId = useClubId(); + const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); const submit = async () => { @@ -50,6 +52,8 @@ export function useCreatePost() { try { const payload = getPayload(); const result = await createPost(clubId, board, payload); + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + toast({ title: '게시글이 작성되었습니다.', variant: 'success' }); reset(); router.push(`/board/${result.id}`); } catch { diff --git a/src/hooks/board/useUpdatePost.ts b/src/hooks/board/useUpdatePost.ts index 8ed9f5a7..b6b011d6 100644 --- a/src/hooks/board/useUpdatePost.ts +++ b/src/hooks/board/useUpdatePost.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; import { updatePost } from '@/lib/actions/board'; import { useClubId } from '@/stores/useClubStore'; import { usePostStore } from '@/stores/usePostStore'; @@ -15,6 +16,7 @@ import { toast } from '@/stores/useToastStore'; export function useUpdatePost() { const router = useRouter(); const clubId = useClubId(); + const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); const submit = async (postId: number) => { @@ -44,6 +46,8 @@ export function useUpdatePost() { setIsPending(true); try { await updatePost(clubId, postId, getPayload()); + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + toast({ title: '게시글이 수정되었습니다.', variant: 'success' }); reset(); router.push(`/board/${postId}`); } catch { From 1a67d5e1c87f09322edea0a8efde5c4cb1d50b6d Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 16:31:31 +0900 Subject: [PATCH 14/43] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=EA=B8=80=EC=93=B0=EA=B8=B0=20=EB=B2=84=ED=8A=BC=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EC=88=98=20=EB=AF=B8=EC=9E=85=EB=A0=A5=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/header/Header.tsx | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 775b025f..992c5f05 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -4,9 +4,18 @@ import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { usePathname } from 'next/navigation'; +import dynamic from 'next/dynamic'; import { Button, Icon } from '@/components/ui'; import { MenuIcon, EditIcon, SendIcon, ExitToAppIcon, AvatarIcon, LogoIcon } from '@/assets/icons'; import { useCreatePost, useUpdatePost } from '@/hooks'; +import { useWritePost } from '@/hooks/home/useWritePost'; + +const CardinalMissingModal = dynamic(() => + import('@/components/home/CardinalMissingModal').then((m) => m.CardinalMissingModal), +); +const ProfileIncompleteModal = dynamic(() => + import('@/components/home/ProfileIncompleteModal').then((m) => m.ProfileIncompleteModal), +); interface HeaderProps { isMain?: boolean; @@ -33,6 +42,14 @@ export default function Header({ isMain = true }: HeaderProps) { const isEditPage = editPostId !== null; const { submit: submitCreate, isPending: isCreating } = useCreatePost(); const { submit: submitUpdate, isPending: isUpdating } = useUpdatePost(); + const { + handleWriteClick, + handleSkipProfile, + cardinalModalOpen, + setCardinalModalOpen, + profileModalOpen, + setProfileModalOpen, + } = useWritePost(); const isPostingPage = isWritePage || isEditPage; const isPending = isCreating || isUpdating; const handleSubmit = () => { @@ -128,7 +145,7 @@ export default function Header({ isMain = true }: HeaderProps) {
)}
+ + setCardinalModalOpen(false)} /> + setProfileModalOpen(false)} + onSkip={handleSkipProfile} + /> ); } From 8beeab920bfb5b1d98e8c2aeb96ddcb3d2480960 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 16:38:45 +0900 Subject: [PATCH 15/43] =?UTF-8?q?fix:=20fileSize=EC=99=80=20contentType?= =?UTF-8?q?=EC=9D=84=20FileAttachment=EC=97=90=EC=84=9C=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/shared/file.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utils/shared/file.ts b/src/utils/shared/file.ts index 7c23ecb3..8453c296 100644 --- a/src/utils/shared/file.ts +++ b/src/utils/shared/file.ts @@ -7,6 +7,8 @@ export function fileAttachmentToFileItem(file: FileAttachment): UploadFileItem { fileName: file.fileName, fileUrl: file.fileUrl, storageKey: file.storageKey, + fileSize: file.fileSize, + contentType: file.contentType, uploaded: true, }; } From 283fad987f9e55a0b45f70dc07d339e7818a201f Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 22:32:02 +0900 Subject: [PATCH 16/43] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=A0=9C=EC=99=B8?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=83=88=EB=A1=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=90=9C=20=ED=8C=8C=EC=9D=BC=EB=A7=8C=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/board/useUpdatePost.ts | 2 +- src/stores/usePostStore.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/board/useUpdatePost.ts b/src/hooks/board/useUpdatePost.ts index b6b011d6..42f55636 100644 --- a/src/hooks/board/useUpdatePost.ts +++ b/src/hooks/board/useUpdatePost.ts @@ -45,7 +45,7 @@ export function useUpdatePost() { setIsPending(true); try { - await updatePost(clubId, postId, getPayload()); + await updatePost(clubId, postId, getPayload(true)); await queryClient.invalidateQueries({ queryKey: ['posts'] }); toast({ title: '게시글이 수정되었습니다.', variant: 'success' }); reset(); diff --git a/src/stores/usePostStore.ts b/src/stores/usePostStore.ts index deac248d..94d0de00 100644 --- a/src/stores/usePostStore.ts +++ b/src/stores/usePostStore.ts @@ -115,13 +115,13 @@ export const usePostStore = create( ); }, - getPayload: () => { + getPayload: (isEdit = false) => { const state = get(); return { title: state.title, content: state.content, files: state.files - .filter((f) => f.uploaded) + .filter((f) => f.uploaded && !(isEdit && f.isExisting)) .map((f) => ({ fileName: f.fileName, storageKey: f.storageKey, From 1b67286b369a06c8efec240fde430e507bb47f5e Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:13:48 +0900 Subject: [PATCH 17/43] =?UTF-8?q?refactor:=20=ED=97=A4=EB=8D=94=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=91=EC=84=B1/=EC=88=98=EC=A0=95=20=EC=A4=91?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layout/header/DefaultActions.tsx | 74 +++++++++++ src/components/layout/header/Header.tsx | 124 ++---------------- .../layout/header/PostingActions.tsx | 60 +++++++++ 3 files changed, 147 insertions(+), 111 deletions(-) create mode 100644 src/components/layout/header/DefaultActions.tsx create mode 100644 src/components/layout/header/PostingActions.tsx diff --git a/src/components/layout/header/DefaultActions.tsx b/src/components/layout/header/DefaultActions.tsx new file mode 100644 index 00000000..ad624bce --- /dev/null +++ b/src/components/layout/header/DefaultActions.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; +import dynamic from 'next/dynamic'; + +import { Button } from '@/components/ui'; +import { EditIcon, ExitToAppIcon, AvatarIcon } from '@/assets/icons'; +import { useWritePost } from '@/hooks/home/useWritePost'; + +const CardinalMissingModal = dynamic(() => + import('@/components/home/CardinalMissingModal').then((m) => m.CardinalMissingModal), +); +const ProfileIncompleteModal = dynamic(() => + import('@/components/home/ProfileIncompleteModal').then((m) => m.ProfileIncompleteModal), +); + +function DefaultActions() { + const router = useRouter(); + const pathname = usePathname(); + const { + handleWriteClick, + handleSkipProfile, + cardinalModalOpen, + setCardinalModalOpen, + profileModalOpen, + setProfileModalOpen, + } = useWritePost(); + + return ( + <> +
+ {pathname.startsWith('/board') && ( + + )} + + +
+ + setCardinalModalOpen(false)} /> + setProfileModalOpen(false)} + onSkip={handleSkipProfile} + /> + + ); +} + +export { DefaultActions }; diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 992c5f05..09df55df 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -2,20 +2,12 @@ import Image from 'next/image'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; import { usePathname } from 'next/navigation'; -import dynamic from 'next/dynamic'; -import { Button, Icon } from '@/components/ui'; -import { MenuIcon, EditIcon, SendIcon, ExitToAppIcon, AvatarIcon, LogoIcon } from '@/assets/icons'; -import { useCreatePost, useUpdatePost } from '@/hooks'; -import { useWritePost } from '@/hooks/home/useWritePost'; -const CardinalMissingModal = dynamic(() => - import('@/components/home/CardinalMissingModal').then((m) => m.CardinalMissingModal), -); -const ProfileIncompleteModal = dynamic(() => - import('@/components/home/ProfileIncompleteModal').then((m) => m.ProfileIncompleteModal), -); +import { MenuIcon, LogoIcon } from '@/assets/icons'; + +import { PostingActions } from './PostingActions'; +import { DefaultActions } from './DefaultActions'; interface HeaderProps { isMain?: boolean; @@ -33,36 +25,15 @@ const Logo = ({ width = 76, href }: { width?: number; href: string }) => ( ); +const NAV_ITEMS = [ + { id: 'board', label: '게시판', href: '/board' }, + { id: 'attendance', label: '출석', href: '/attendance' }, +]; + export default function Header({ isMain = true }: HeaderProps) { const pathname = usePathname(); - const router = useRouter(); - const isWritePage = pathname.includes('/write'); - const editMatch = pathname.match(/^\/board\/edit\/(\d+)$/); - const editPostId = editMatch ? Number(editMatch[1]) : null; - const isEditPage = editPostId !== null; - const { submit: submitCreate, isPending: isCreating } = useCreatePost(); - const { submit: submitUpdate, isPending: isUpdating } = useUpdatePost(); - const { - handleWriteClick, - handleSkipProfile, - cardinalModalOpen, - setCardinalModalOpen, - profileModalOpen, - setProfileModalOpen, - } = useWritePost(); - const isPostingPage = isWritePage || isEditPage; - const isPending = isCreating || isUpdating; - const handleSubmit = () => { - if (isEditPage && editPostId !== null) { - submitUpdate(editPostId); - } else { - submitCreate(); - } - }; - const navItems = [ - { id: 'board', label: '게시판', href: '/board' }, - { id: 'attendance', label: '출석', href: '/attendance' }, - ]; + const isPostingPage = pathname.includes('/write') || /^\/board\/edit\/\d+$/.test(pathname); + return ( <>
@@ -93,7 +64,7 @@ export default function Header({ isMain = true }: HeaderProps) { )} {isMain && - navItems.map(({ id, label, href }) => { + NAV_ITEMS.map(({ id, label, href }) => { const isActive = pathname.startsWith(href); return ( - {isMain && ( -
- {isPostingPage ? ( - <> - - - - ) : ( - <> - {pathname.startsWith('/board') && ( - - )} - - - - )} -
- )} + {isMain && (isPostingPage ? : )}
- - setCardinalModalOpen(false)} /> - setProfileModalOpen(false)} - onSkip={handleSkipProfile} - /> ); } diff --git a/src/components/layout/header/PostingActions.tsx b/src/components/layout/header/PostingActions.tsx new file mode 100644 index 00000000..d38f6228 --- /dev/null +++ b/src/components/layout/header/PostingActions.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; + +import { Button, Icon } from '@/components/ui'; +import { SendIcon } from '@/assets/icons'; +import { useCreatePost, useUpdatePost } from '@/hooks'; + +function PostingActions() { + const router = useRouter(); + const pathname = usePathname(); + + const editMatch = pathname.match(/^\/board\/edit\/(\d+)$/); + const editPostId = editMatch ? Number(editMatch[1]) : null; + const isEditPage = editPostId !== null; + + const { submit: submitCreate, isPending: isCreating } = useCreatePost(); + const { submit: submitUpdate, isPending: isUpdating } = useUpdatePost(); + const isPending = isCreating || isUpdating; + + const handleSubmit = () => { + if (isEditPage && editPostId !== null) { + submitUpdate(editPostId); + } else { + submitCreate(); + } + }; + + return ( +
+ + +
+ ); +} + +export { PostingActions }; From 4cc519d0fb775df213b2192268514b5fc05a1f72 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:15:20 +0900 Subject: [PATCH 18/43] =?UTF-8?q?refactor:=20validatePost=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A1=9C=20=EA=B3=B5=ED=86=B5=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/board/useCreatePost.ts | 28 +++---------------------- src/hooks/board/useUpdatePost.ts | 24 +++------------------- src/hooks/board/validatePost.ts | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 46 deletions(-) create mode 100644 src/hooks/board/validatePost.ts diff --git a/src/hooks/board/useCreatePost.ts b/src/hooks/board/useCreatePost.ts index 3f20a726..9e9eebe5 100644 --- a/src/hooks/board/useCreatePost.ts +++ b/src/hooks/board/useCreatePost.ts @@ -5,13 +5,10 @@ import { createPost } from '@/lib/actions/board'; import { useClubId } from '@/stores/useClubStore'; import { usePostStore } from '@/stores/usePostStore'; import { toast } from '@/stores/useToastStore'; +import { validatePost } from './validatePost'; /** * 게시글 작성 Server Action 호출 훅 - * - * - usePostStore에서 board, payload를 꺼내 createPost Server Action 호출 - * - 성공 시 store reset + 게시글 상세로 이동 - * - 업로드 미완료 파일이 있으면 제출 차단 */ export function useCreatePost() { const router = useRouter(); @@ -22,36 +19,17 @@ export function useCreatePost() { const submit = async () => { const { board, title, content, files, getPayload, reset } = usePostStore.getState(); - if (!clubId) { - toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); - return; - } - if (!board) { toast({ title: '게시판을 선택해주세요.', variant: 'error' }); return; } - if (!title.trim()) { - toast({ title: '제목을 입력해주세요.', variant: 'error' }); - return; - } - - if (!content.trim()) { - toast({ title: '내용을 입력해주세요.', variant: 'error' }); - return; - } - - const uploading = files.some((f) => !f.uploaded && f.file); - if (uploading) { - toast({ title: '파일 업로드가 진행 중입니다.', variant: 'error' }); - return; - } + if (!validatePost({ clubId, title, content, files })) return; setIsPending(true); try { const payload = getPayload(); - const result = await createPost(clubId, board, payload); + const result = await createPost(clubId!, board, payload); await queryClient.invalidateQueries({ queryKey: ['posts'] }); toast({ title: '게시글이 작성되었습니다.', variant: 'success' }); reset(); diff --git a/src/hooks/board/useUpdatePost.ts b/src/hooks/board/useUpdatePost.ts index 42f55636..1ed5ac29 100644 --- a/src/hooks/board/useUpdatePost.ts +++ b/src/hooks/board/useUpdatePost.ts @@ -5,6 +5,7 @@ import { updatePost } from '@/lib/actions/board'; import { useClubId } from '@/stores/useClubStore'; import { usePostStore } from '@/stores/usePostStore'; import { toast } from '@/stores/useToastStore'; +import { validatePost } from './validatePost'; /** * 게시글 수정 Server Action 호출 훅 @@ -22,30 +23,11 @@ export function useUpdatePost() { const submit = async (postId: number) => { const { title, content, files, getPayload, reset } = usePostStore.getState(); - if (!clubId) { - toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); - return; - } - - if (!title.trim()) { - toast({ title: '제목을 입력해주세요.', variant: 'error' }); - return; - } - - if (!content.trim()) { - toast({ title: '내용을 입력해주세요.', variant: 'error' }); - return; - } - - const uploading = files.some((f) => !f.uploaded && f.file); - if (uploading) { - toast({ title: '파일 업로드가 진행 중입니다.', variant: 'error' }); - return; - } + if (!validatePost({ clubId, title, content, files })) return; setIsPending(true); try { - await updatePost(clubId, postId, getPayload(true)); + await updatePost(clubId!, postId, getPayload(true)); await queryClient.invalidateQueries({ queryKey: ['posts'] }); toast({ title: '게시글이 수정되었습니다.', variant: 'success' }); reset(); diff --git a/src/hooks/board/validatePost.ts b/src/hooks/board/validatePost.ts new file mode 100644 index 00000000..566c9046 --- /dev/null +++ b/src/hooks/board/validatePost.ts @@ -0,0 +1,35 @@ +import type { UploadFileItem } from '@/stores/usePostStore'; +import { toast } from '@/stores/useToastStore'; +import { isHtmlEmpty } from '@/utils/shared'; + +interface ValidatePostParams { + clubId: string | null; + title: string; + content: string; + files: UploadFileItem[]; +} + +export function validatePost({ clubId, title, content, files }: ValidatePostParams): boolean { + if (!clubId) { + toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); + return false; + } + + if (!title.trim()) { + toast({ title: '제목을 입력해주세요.', variant: 'error' }); + return false; + } + + if (isHtmlEmpty(content)) { + toast({ title: '내용을 입력해주세요.', variant: 'error' }); + return false; + } + + const uploading = files.some((f) => !f.uploaded && f.file); + if (uploading) { + toast({ title: '파일 업로드가 진행 중입니다.', variant: 'error' }); + return false; + } + + return true; +} From f72541b597b23267dfbdd30120a1aea35a3bab59 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:17:39 +0900 Subject: [PATCH 19/43] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=94=8C=EB=A6=AC=ED=8C=85=20=EC=98=A4=EB=B2=84=ED=97=A4?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/PostActionMenu.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/board/PostActionMenu.tsx b/src/components/board/PostActionMenu.tsx index 777f3720..a1d8d28e 100644 --- a/src/components/board/PostActionMenu.tsx +++ b/src/components/board/PostActionMenu.tsx @@ -1,12 +1,8 @@ 'use client'; import { useState } from 'react'; -import dynamic from 'next/dynamic'; import { ActionMenu, type ActionMenuProps } from './ActionMenu'; - -const PostDeleteDialog = dynamic(() => - import('./PostDeleteDialog').then((mod) => mod.PostDeleteDialog), -); +import { PostDeleteDialog } from './PostDeleteDialog'; interface PostActionMenuProps extends Omit { postId: number; @@ -15,9 +11,6 @@ interface PostActionMenuProps extends Omit { /** * 게시글 전용 수정/삭제 메뉴 - * - * 삭제 선택 시 확인 다이얼로그를 띄우고 내부에서 deletePost API를 호출 - * 댓글/답글 등 범용 용도에는 `ActionMenu`를 사용 */ function PostActionMenu({ postId, onDeleted, ...rest }: PostActionMenuProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); From faa08e024e8c4e0a04d849744b55b38d385a7d95 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:20:07 +0900 Subject: [PATCH 20/43] =?UTF-8?q?refactor:=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20useLineClamp=20=ED=9B=85=EC=9D=84=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/PostCard/ExpandButton.tsx | 21 +++++ .../board/PostCard/PostCardContent.tsx | 84 ++----------------- .../board/PostCard/PostCardTitle.tsx | 28 +++++++ src/hooks/index.ts | 1 + src/hooks/useLineClamp.ts | 33 ++++++++ 5 files changed, 90 insertions(+), 77 deletions(-) create mode 100644 src/components/board/PostCard/ExpandButton.tsx create mode 100644 src/components/board/PostCard/PostCardTitle.tsx create mode 100644 src/hooks/useLineClamp.ts diff --git a/src/components/board/PostCard/ExpandButton.tsx b/src/components/board/PostCard/ExpandButton.tsx new file mode 100644 index 00000000..198c8639 --- /dev/null +++ b/src/components/board/PostCard/ExpandButton.tsx @@ -0,0 +1,21 @@ +interface ExpandButtonProps { + onExpand: () => void; +} + +function ExpandButton({ onExpand }: ExpandButtonProps) { + return ( + + ); +} + +export { ExpandButton, type ExpandButtonProps }; diff --git a/src/components/board/PostCard/PostCardContent.tsx b/src/components/board/PostCard/PostCardContent.tsx index 640991b0..31c6426f 100644 --- a/src/components/board/PostCard/PostCardContent.tsx +++ b/src/components/board/PostCard/PostCardContent.tsx @@ -1,81 +1,10 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import Image from 'next/image'; -import { NewIcon } from '@/assets/icons'; import { cn } from '@/lib/cn'; +import { useLineClamp } from '@/hooks/useLineClamp'; -interface PostCardTitleProps { - title: string; - isNew?: boolean; - size: 'list' | 'detail'; -} - -function PostCardTitle({ title, isNew, size }: PostCardTitleProps) { - return ( -
-

- {title} -

- {isNew && ( - <> - - 새 글 - - )} -
- ); -} - -function useLineClamp(enabled: boolean, deps: unknown[]) { - const ref = useRef(null); - const [isClamped, setIsClamped] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - - useEffect(() => { - const el = ref.current; - if (!el || !enabled) return; - - const check = () => { - const prevDisplay = el.style.display; - const prevClamp = el.style.webkitLineClamp; - el.style.display = 'block'; - el.style.webkitLineClamp = 'unset'; - const fullHeight = el.scrollHeight; - el.style.display = prevDisplay; - el.style.webkitLineClamp = prevClamp; - setIsClamped(fullHeight > el.clientHeight); - }; - - check(); - - const ro = new ResizeObserver(check); - ro.observe(el); - return () => ro.disconnect(); - }, [enabled, ...deps]); - - return { ref, isClamped, isExpanded, setIsExpanded }; -} - -interface ExpandButtonProps { - onExpand: () => void; -} - -function ExpandButton({ onExpand }: ExpandButtonProps) { - return ( - - ); -} +import { PostCardTitle } from './PostCardTitle'; +import { ExpandButton } from './ExpandButton'; interface PostCardListContentProps { className?: string; @@ -94,7 +23,7 @@ function PostCardListContent({ }: PostCardListContentProps) { const { ref, isClamped, isExpanded, setIsExpanded } = useLineClamp( expandable, - [content], + content, ); const plainContent = content @@ -138,9 +67,10 @@ function PostCardDetailContent({ isNew, expandable = false, }: PostCardDetailContentProps) { - const { ref, isClamped, isExpanded, setIsExpanded } = useLineClamp(expandable, [ + const { ref, isClamped, isExpanded, setIsExpanded } = useLineClamp( + expandable, content, - ]); + ); return (
diff --git a/src/components/board/PostCard/PostCardTitle.tsx b/src/components/board/PostCard/PostCardTitle.tsx new file mode 100644 index 00000000..ab7ed5c5 --- /dev/null +++ b/src/components/board/PostCard/PostCardTitle.tsx @@ -0,0 +1,28 @@ +import Image from 'next/image'; + +import { NewIcon } from '@/assets/icons'; +import { cn } from '@/lib/cn'; + +interface PostCardTitleProps { + title: string; + isNew?: boolean; + size: 'list' | 'detail'; +} + +function PostCardTitle({ title, isNew, size }: PostCardTitleProps) { + return ( +
+

+ {title} +

+ {isNew && ( + <> + + 새 글 + + )} +
+ ); +} + +export { PostCardTitle, type PostCardTitleProps }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 78eb771e..63130fae 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -12,5 +12,6 @@ export { useCreatePost } from './board/useCreatePost'; export { useUpdatePost } from './board/useUpdatePost'; export { useDeletePost } from './board/useDeletePost'; export { useIntersectionObserver } from './board/useIntersectionObserver'; +export { useLineClamp } from './useLineClamp'; export { useNavigationGuard } from './useNavigationGuard'; export { useProgressAnimation } from './useProgressAnimation'; diff --git a/src/hooks/useLineClamp.ts b/src/hooks/useLineClamp.ts new file mode 100644 index 00000000..2ed55717 --- /dev/null +++ b/src/hooks/useLineClamp.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef, useState } from 'react'; + +function useLineClamp(enabled: boolean, content: string) { + const ref = useRef(null); + const [isClamped, setIsClamped] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || !enabled) return; + + const check = () => { + const prevDisplay = el.style.display; + const prevClamp = el.style.webkitLineClamp; + el.style.display = 'block'; + el.style.webkitLineClamp = 'unset'; + const fullHeight = el.scrollHeight; + el.style.display = prevDisplay; + el.style.webkitLineClamp = prevClamp; + setIsClamped(fullHeight > el.clientHeight); + }; + + check(); + + const ro = new ResizeObserver(check); + ro.observe(el); + return () => ro.disconnect(); + }, [enabled, content]); + + return { ref, isClamped, isExpanded, setIsExpanded }; +} + +export { useLineClamp }; From ef211b5ce11dfc23b524d10807b14732a181d196 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:23:47 +0900 Subject: [PATCH 21/43] =?UTF-8?q?fix:=20contentType=20=EB=B9=88=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=B2=98=EB=A6=AC=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/board/useFileAttach.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/board/useFileAttach.ts b/src/hooks/board/useFileAttach.ts index 4fd7f451..abcd5ef9 100644 --- a/src/hooks/board/useFileAttach.ts +++ b/src/hooks/board/useFileAttach.ts @@ -37,7 +37,7 @@ export function useFileAttach() { fileName: selected.name, fileUrl: URL.createObjectURL(selected), fileSize: selected.size, - contentType: selected.type, + contentType: selected.type || 'application/octet-stream', storageKey: '', uploaded: false, }); From aab38b06d845010d90d031097efd06aef85fb8c9 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:24:30 +0900 Subject: [PATCH 22/43] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20hasChanges=20=EC=98=A4?= =?UTF-8?q?=ED=83=90=20=EA=B0=80=EB=8A=A5=EC=84=B1=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/PostEditorShell.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/board/PostEditorShell.tsx b/src/components/board/PostEditorShell.tsx index a754fd98..89107e2c 100644 --- a/src/components/board/PostEditorShell.tsx +++ b/src/components/board/PostEditorShell.tsx @@ -17,9 +17,6 @@ interface PostEditorShellProps { align?: 'start' | 'center'; } -/** - * title 구독을 shell 밖으로 격리 - */ function BoundTitleInput() { const title = usePostStore((s) => s.title); const setTitle = usePostStore((s) => s.setTitle); @@ -28,18 +25,18 @@ function BoundTitleInput() { /** * 게시글 작성/수정 페이지 공통 레이아웃 Shell - * - * - 외곽 레이아웃 + TitleInput + Editor 렌더링을 담당 - * - 상단 영역(header)과 Editor 초기 콘텐츠는 주입 - * - Store 초기화는 사용하는 쪽에서 책임 (write: reset, edit: 기존 데이터 복원) - * - 브라우저 뒤로가기 / 탭 닫기 시 navigation guard 제공 */ function PostEditorShell({ header, initialContent, align = 'start' }: PostEditorShellProps) { const title = usePostStore((s) => s.title); const content = usePostStore((s) => s.content); const files = usePostStore((s) => s.files); + const snapshot = usePostStore((s) => s._snapshot); - const hasChanges = title.length > 0 || content.length > 0 || files.length > 0; + const hasChanges = snapshot + ? title !== snapshot.title || + content !== snapshot.content || + files.map((f) => f.id).join(',') !== snapshot.fileIds.join(',') + : title.length > 0 || content.length > 0 || files.length > 0; const { open, onConfirm, onCancel } = useNavigationGuard({ enabled: hasChanges }); return ( From dcf94ef37a0fc8902c669cf713c63a93f7f77908 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:26:11 +0900 Subject: [PATCH 23/43] =?UTF-8?q?comment:=20=EC=9D=98=EB=8F=84=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EB=A7=88=EC=9A=B4=ED=8A=B8=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?effect=EC=9D=98=20ESLint=20=EA=B2=BD=EA=B3=A0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/board/write/ClientEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(private)/(main)/board/write/ClientEditor.tsx b/src/app/(private)/(main)/board/write/ClientEditor.tsx index e7750fc8..66ac0553 100644 --- a/src/app/(private)/(main)/board/write/ClientEditor.tsx +++ b/src/app/(private)/(main)/board/write/ClientEditor.tsx @@ -17,12 +17,12 @@ export default function ClientEditor() { const setBoard = usePostStore((s) => s.setBoard); const reset = usePostStore((s) => s.reset); - // 글쓰기 페이지 진입 시 store 초기화 후 현재 게시판으로 설정 useEffect(() => { reset(); if (activeBoardId !== null) { setBoard(activeBoardId); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- 마운트 시 1회만 실행 }, []); const activeId = board ?? activeBoardId ?? items.find((item) => item.type !== 'ALL')?.id ?? null; From 78846af14aa786f46fcd2333bb7acf08cae390bc Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:28:20 +0900 Subject: [PATCH 24/43] =?UTF-8?q?refactor:=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=EC=A4=91=20store=20=EB=B3=80=EA=B2=BD=EC=9D=84=20useEffect=20+?= =?UTF-8?q?=20ref=20guard=20=ED=8C=A8=ED=84=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(private)/(main)/board/edit/[id]/EditClientEditor.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx b/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx index 93f5cc5a..5bb7f511 100644 --- a/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx +++ b/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { CategorySelector, PostEditorShell } from '@/components/board'; import { useBoardList } from '@/hooks'; @@ -14,10 +14,12 @@ interface EditClientEditorProps { function EditClientEditor({ post }: EditClientEditorProps) { const initializedRef = useRef(false); - if (!initializedRef.current) { + + useEffect(() => { + if (initializedRef.current) return; initializedRef.current = true; usePostStore.getState().initFromDetail(post); - } + }, [post]); const { data: boards } = useBoardList(); const items = boards?.map(toBoardNavItem) ?? []; From dbc58144e54fb52cb8e979b7cde9053168c2fa68 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:32:18 +0900 Subject: [PATCH 25/43] =?UTF-8?q?refactor:=20PostCardContent=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/PostCard/PostCardDetailContent.tsx | 47 +++++++++++++++++++ ...ardContent.tsx => PostCardListContent.tsx} | 45 +----------------- src/components/board/PostCard/index.tsx | 3 +- 3 files changed, 50 insertions(+), 45 deletions(-) create mode 100644 src/components/board/PostCard/PostCardDetailContent.tsx rename src/components/board/PostCard/{PostCardContent.tsx => PostCardListContent.tsx} (54%) diff --git a/src/components/board/PostCard/PostCardDetailContent.tsx b/src/components/board/PostCard/PostCardDetailContent.tsx new file mode 100644 index 00000000..d834c0f7 --- /dev/null +++ b/src/components/board/PostCard/PostCardDetailContent.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { cn } from '@/lib/cn'; +import { useLineClamp } from '@/hooks/useLineClamp'; + +import { PostCardTitle } from './PostCardTitle'; +import { ExpandButton } from './ExpandButton'; + +interface PostCardDetailContentProps { + className?: string; + title: string; + content: string; + isNew?: boolean; + expandable?: boolean; +} + +function PostCardDetailContent({ + className, + title, + content, + isNew, + expandable = false, +}: PostCardDetailContentProps) { + const { ref, isClamped, isExpanded, setIsExpanded } = useLineClamp( + expandable, + content, + ); + + return ( +
+ +
+ {expandable && isClamped && !isExpanded && ( + setIsExpanded(true)} /> + )} +
+ ); +} + +export { PostCardDetailContent, type PostCardDetailContentProps }; diff --git a/src/components/board/PostCard/PostCardContent.tsx b/src/components/board/PostCard/PostCardListContent.tsx similarity index 54% rename from src/components/board/PostCard/PostCardContent.tsx rename to src/components/board/PostCard/PostCardListContent.tsx index 31c6426f..f456be99 100644 --- a/src/components/board/PostCard/PostCardContent.tsx +++ b/src/components/board/PostCard/PostCardListContent.tsx @@ -52,47 +52,4 @@ function PostCardListContent({ ); } -interface PostCardDetailContentProps { - className?: string; - title: string; - content: string; - isNew?: boolean; - expandable?: boolean; -} - -function PostCardDetailContent({ - className, - title, - content, - isNew, - expandable = false, -}: PostCardDetailContentProps) { - const { ref, isClamped, isExpanded, setIsExpanded } = useLineClamp( - expandable, - content, - ); - - return ( -
- -
- {expandable && isClamped && !isExpanded && ( - setIsExpanded(true)} /> - )} -
- ); -} - -export { - PostCardListContent, - PostCardDetailContent, - type PostCardListContentProps, - type PostCardDetailContentProps, -}; +export { PostCardListContent, type PostCardListContentProps }; diff --git a/src/components/board/PostCard/index.tsx b/src/components/board/PostCard/index.tsx index 5358eef8..c482a3ff 100644 --- a/src/components/board/PostCard/index.tsx +++ b/src/components/board/PostCard/index.tsx @@ -2,7 +2,8 @@ import { cn } from '@/lib/cn'; import { ImageList } from '@/components/board/ImageList'; import type { DisplayFile } from '@/types/board'; import { PostAuthorInfo } from './PostAuthorInfo'; -import { PostCardListContent, PostCardDetailContent } from './PostCardContent'; +import { PostCardListContent } from './PostCardListContent'; +import { PostCardDetailContent } from './PostCardDetailContent'; import { PostCardActions, type PostCardActionsProps } from './PostCardActions'; function PostCardRoot({ className, children, ...props }: React.ComponentProps<'article'>) { From a442b8fb71f5daf7781346eda3cd1494111f5472 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:36:15 +0900 Subject: [PATCH 26/43] =?UTF-8?q?fix:=20ActionMenu=20=EC=98=81=EC=97=AD?= =?UTF-8?q?=EC=9D=84=20Button=EC=9D=98=20onClickCapture=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=EB=A5=BC=20=EB=A7=89=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/ActionMenu.tsx | 31 +++++++++++------------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/components/board/ActionMenu.tsx b/src/components/board/ActionMenu.tsx index b0dd1e72..8dce1aec 100644 --- a/src/components/board/ActionMenu.tsx +++ b/src/components/board/ActionMenu.tsx @@ -38,25 +38,18 @@ function ActionMenu({ }: ActionMenuProps) { return ( - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} - { - e.preventDefault(); - e.stopPropagation(); - }} - > - - - - + + + 수정 From caf69d57be980f31bcca96e754b3a98f6ca23157 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:42:39 +0900 Subject: [PATCH 27/43] =?UTF-8?q?refactor:=20TitleInput=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoundTitleInput.tsx | 15 +++++++++++++++ src/components/board/PostEditorShell.tsx | 9 ++------- 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 src/components/board/BoundTitleInput.tsx diff --git a/src/components/board/BoundTitleInput.tsx b/src/components/board/BoundTitleInput.tsx new file mode 100644 index 00000000..3c96db84 --- /dev/null +++ b/src/components/board/BoundTitleInput.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { TitleInput } from './TitleInput'; +import { usePostStore } from '@/stores/usePostStore'; + +/** + * PostStore에 바인딩된 TitleInput + */ +function BoundTitleInput() { + const title = usePostStore((s) => s.title); + const setTitle = usePostStore((s) => s.setTitle); + return setTitle(e.target.value)} />; +} + +export { BoundTitleInput }; diff --git a/src/components/board/PostEditorShell.tsx b/src/components/board/PostEditorShell.tsx index 89107e2c..5e46bac9 100644 --- a/src/components/board/PostEditorShell.tsx +++ b/src/components/board/PostEditorShell.tsx @@ -3,12 +3,13 @@ import dynamic from 'next/dynamic'; import type { ReactNode } from 'react'; -import { TitleInput } from './TitleInput'; import { AlertDialog, AlertDialogAction, AlertDialogCancel } from '@/components/ui'; import { useNavigationGuard } from '@/hooks'; import { cn } from '@/lib/cn'; import { usePostStore } from '@/stores/usePostStore'; +import { BoundTitleInput } from './BoundTitleInput'; + const Editor = dynamic(() => import('./Editor'), { ssr: false }); interface PostEditorShellProps { @@ -17,12 +18,6 @@ interface PostEditorShellProps { align?: 'start' | 'center'; } -function BoundTitleInput() { - const title = usePostStore((s) => s.title); - const setTitle = usePostStore((s) => s.setTitle); - return setTitle(e.target.value)} />; -} - /** * 게시글 작성/수정 페이지 공통 레이아웃 Shell */ From cb09526514695fff7ec43e749bea97d4950aa2c4 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:47:44 +0900 Subject: [PATCH 28/43] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20ID?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/board/edit/[id]/page.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/(private)/(main)/board/edit/[id]/page.tsx b/src/app/(private)/(main)/board/edit/[id]/page.tsx index 22f764d0..4e8fcf18 100644 --- a/src/app/(private)/(main)/board/edit/[id]/page.tsx +++ b/src/app/(private)/(main)/board/edit/[id]/page.tsx @@ -1,3 +1,5 @@ +import { notFound } from 'next/navigation'; + import { boardServerApi } from '@/lib/apis/board.server'; import { EditClientEditor } from './EditClientEditor'; @@ -7,8 +9,18 @@ interface PostEditPageProps { export default async function PostEditPage({ params }: PostEditPageProps) { const { id } = await params; + const postId = Number(id); + + if (Number.isNaN(postId)) { + notFound(); + } + // TODO: 추후 하드코딩된 clubId 제거 예정 - const response = await boardServerApi.getPostById('YUNJcjFKMO', Number(id)); + const response = await boardServerApi.getPostById('YUNJcjFKMO', postId).catch(() => null); + + if (!response?.data) { + notFound(); + } return (
From bbd777698c383b6af74f002cfe9c900c269698ff Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:47:58 +0900 Subject: [PATCH 29/43] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=BD=EA=B3=A0=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=ED=91=9C=EC=8B=9C=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stores/usePostStore.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/stores/usePostStore.ts b/src/stores/usePostStore.ts index 94d0de00..b43a148a 100644 --- a/src/stores/usePostStore.ts +++ b/src/stores/usePostStore.ts @@ -12,10 +12,15 @@ export interface UploadFileItem { fileSize: number; contentType: string; uploaded: boolean; - /** 서버에서 불러온 기존 파일인지 여부 (수정 시 재전송 방지) */ isExisting?: boolean; } +interface Snapshot { + title: string; + content: string; + fileIds: string[]; +} + const initialState = { board: null as number | null, title: '', @@ -27,6 +32,7 @@ const initialState = { content: '', files: [] as UploadFileItem[], status: 'DRAFT' as 'DRAFT' | 'PUBLISHED', + _snapshot: null as Snapshot | null, }; export type PostState = typeof initialState; @@ -84,15 +90,13 @@ export const usePostStore = create( set(initialState, false, 'reset'); }, - /** - * 기존 게시글 상세 데이터로 스토어를 초기화 (수정 페이지 진입 시 사용) - * - 내부적으로 reset을 수행한 뒤 PostDetail의 필드를 스토어 상태로 매핑 - */ initFromDetail: (post: PostDetail) => { const { files } = get(); for (const f of files) { if (f.fileUrl.startsWith('blob:')) URL.revokeObjectURL(f.fileUrl); } + + const fileIds = post.fileUrls.map((f) => String(f.fileId)); set( { ...initialState, @@ -109,6 +113,7 @@ export const usePostStore = create( uploaded: true, isExisting: true, })), + _snapshot: { title: post.title, content: post.content, fileIds }, }, false, 'initFromDetail', From db3d446fcef3e185b53a5f2ca4495b6957976cb4 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:48:20 +0900 Subject: [PATCH 30/43] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=A1=9C=EB=94=A9=20=EC=A4=91=20=EA=B8=80?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=98=EB=AA=BB=EB=90=9C=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/home/useWritePost.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/home/useWritePost.ts b/src/hooks/home/useWritePost.ts index 73fb82aa..4c62d5b9 100644 --- a/src/hooks/home/useWritePost.ts +++ b/src/hooks/home/useWritePost.ts @@ -6,12 +6,14 @@ import { useProfileStatusQuery } from './useProfileStatusQuery'; export function useWritePost() { const router = useRouter(); - const { data: profileStatus } = useProfileStatusQuery(); + const { data: profileStatus, isLoading } = useProfileStatusQuery(); const [cardinalModalOpen, setCardinalModalOpen] = useState(false); const [profileModalOpen, setProfileModalOpen] = useState(false); const handleWriteClick = () => { + if (isLoading) return; + if (!profileStatus?.cardinalAssigned) { setCardinalModalOpen(true); } else if (!profileStatus?.profileCompleted) { From 8ae77f638921f7a9efc829e02509f70cf14e2991 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 18:48:52 +0900 Subject: [PATCH 31/43] =?UTF-8?q?refactor:=20isHtmlEmpty=EB=A5=BC=20shared?= =?UTF-8?q?=EC=97=90=EC=84=9C=20board=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/board/validatePost.ts | 2 +- src/utils/board/isHtmlEmpty.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/utils/board/isHtmlEmpty.ts diff --git a/src/hooks/board/validatePost.ts b/src/hooks/board/validatePost.ts index 566c9046..b2e42137 100644 --- a/src/hooks/board/validatePost.ts +++ b/src/hooks/board/validatePost.ts @@ -1,6 +1,6 @@ import type { UploadFileItem } from '@/stores/usePostStore'; import { toast } from '@/stores/useToastStore'; -import { isHtmlEmpty } from '@/utils/shared'; +import { isHtmlEmpty } from '@/utils/board/isHtmlEmpty'; interface ValidatePostParams { clubId: string | null; diff --git a/src/utils/board/isHtmlEmpty.ts b/src/utils/board/isHtmlEmpty.ts new file mode 100644 index 00000000..a5d2e63a --- /dev/null +++ b/src/utils/board/isHtmlEmpty.ts @@ -0,0 +1,8 @@ +/** + * Tiptap HTML 콘텐츠가 비어있는지 확인 + * 빈 에디터는 `

` 같은 빈 태그를 반환하므로 trim()으로는 감지 불가 + */ +export function isHtmlEmpty(html: string): boolean { + const text = html.replace(/<[^>]*>/g, '').trim(); + return text.length === 0; +} From 7849f1165a0861c244c7cbf87761906dda70ad91 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 17:43:08 +0900 Subject: [PATCH 32/43] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B9=B4=EB=93=9C=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=AC=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoardContent.tsx | 41 ++++++++++++++++---------- src/hooks/board/resolveFilesPayload.ts | 29 ++++++++++++++++++ src/hooks/board/useUpdatePost.ts | 15 ++++------ src/types/board.ts | 10 +++++-- 4 files changed, 67 insertions(+), 28 deletions(-) create mode 100644 src/hooks/board/resolveFilesPayload.ts diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 970cb501..d292d65e 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -47,22 +47,31 @@ function BoardContent() { return (
- {(posts ?? []).map((post) => ( - - - - - {currentUserId === post.author.id && } - - - - - - ))} + {(posts ?? []).map((post) => { + const imageFiles = post.fileUrls + .filter((f) => f.contentType.startsWith('image/')) + .map((f) => ({ id: f.fileId, fileName: f.fileName, fileUrl: f.fileUrl, uploaded: true })); + + return ( + + + + 0} + /> + {currentUserId === post.author.id && } + + +
{ e.preventDefault(); e.stopPropagation(); }}> + +
+ +
+ + ); + })} {isFetchingNextPage && }
diff --git a/src/hooks/board/resolveFilesPayload.ts b/src/hooks/board/resolveFilesPayload.ts new file mode 100644 index 00000000..6fa7424d --- /dev/null +++ b/src/hooks/board/resolveFilesPayload.ts @@ -0,0 +1,29 @@ +import type { UploadFileItem } from '@/stores/usePostStore'; +import type { CreatePostFile } from '@/types/board'; + +function resolveFilesPayload( + uploadedFiles: UploadFileItem[], + snapshotFileIds: string[] | null, +): CreatePostFile[] | null { + const toCreatePostFile = (f: UploadFileItem): CreatePostFile => ({ + fileName: f.fileName, + storageKey: f.storageKey, + fileSize: f.fileSize, + contentType: f.contentType, + }); + + if (snapshotFileIds === null) { + return uploadedFiles.map(toCreatePostFile); + } + + const currentExistingIds = uploadedFiles.filter((f) => f.isExisting).map((f) => f.id); + const hasNewFiles = uploadedFiles.some((f) => !f.isExisting); + const filesUnchanged = + !hasNewFiles && + currentExistingIds.length === snapshotFileIds.length && + snapshotFileIds.every((id) => currentExistingIds.includes(id)); + + return filesUnchanged ? null : uploadedFiles.map(toCreatePostFile); +} + +export { resolveFilesPayload }; diff --git a/src/hooks/board/useUpdatePost.ts b/src/hooks/board/useUpdatePost.ts index 1ed5ac29..0df65582 100644 --- a/src/hooks/board/useUpdatePost.ts +++ b/src/hooks/board/useUpdatePost.ts @@ -2,18 +2,12 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { updatePost } from '@/lib/actions/board'; +import { resolveFilesPayload } from './resolveFilesPayload'; import { useClubId } from '@/stores/useClubStore'; import { usePostStore } from '@/stores/usePostStore'; import { toast } from '@/stores/useToastStore'; import { validatePost } from './validatePost'; -/** - * 게시글 수정 Server Action 호출 훅 - * - * - usePostStore에서 title, content, files를 꺼내 updatePost Server Action 호출 - * - 성공 시 store reset + 게시글 상세로 이동 - * - 업로드 미완료 파일이 있으면 제출 차단 - */ export function useUpdatePost() { const router = useRouter(); const clubId = useClubId(); @@ -21,13 +15,16 @@ export function useUpdatePost() { const [isPending, setIsPending] = useState(false); const submit = async (postId: number) => { - const { title, content, files, getPayload, reset } = usePostStore.getState(); + const { title, content, files, _snapshot, reset } = usePostStore.getState(); if (!validatePost({ clubId, title, content, files })) return; + const uploadedFiles = files.filter((f) => f.uploaded); + const filesPayload = resolveFilesPayload(uploadedFiles, _snapshot?.fileIds ?? null); + setIsPending(true); try { - await updatePost(clubId!, postId, getPayload(true)); + await updatePost(clubId!, postId, { title, content, files: filesPayload }); await queryClient.invalidateQueries({ queryKey: ['posts'] }); toast({ title: '게시글이 수정되었습니다.', variant: 'success' }); reset(); diff --git a/src/types/board.ts b/src/types/board.ts index 224713bd..9866f0bf 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -45,7 +45,7 @@ interface PostBase { } export interface PostListItem extends PostBase { - hasFile: boolean; + fileUrls: FileItem[]; isNew: boolean; } @@ -103,8 +103,12 @@ export interface CreatePostData { id: number; } -/** 게시글 수정 요청 body */ -export type UpdatePostBody = CreatePostBody; +/** 게시글 수정 요청 body — files: null=변경 없음, []=전체 삭제, 배열=교체 */ +export interface UpdatePostBody { + title: string; + content: string; + files: CreatePostFile[] | null; +} /** 게시글 수정 응답 data */ export interface UpdatePostData { From 0f5e0e7c316c263d5375ef1e847ab0a56baaec48 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 17:45:21 +0900 Subject: [PATCH 33/43] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A3=BC=EC=84=9D=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/board/useBoardQuery.ts | 4 ---- src/hooks/board/useCreatePost.ts | 3 --- src/hooks/board/useDeletePost.ts | 7 ------- src/hooks/board/useFileAttach.ts | 9 --------- 4 files changed, 23 deletions(-) diff --git a/src/hooks/board/useBoardQuery.ts b/src/hooks/board/useBoardQuery.ts index ed97d487..4cb90856 100644 --- a/src/hooks/board/useBoardQuery.ts +++ b/src/hooks/board/useBoardQuery.ts @@ -24,10 +24,6 @@ export function useBoardList() { }); } -/** - * activeBoardId가 null이면 전체 게시글, 아니면 게시판별 게시글 조회. - * useInfiniteQuery로 무한스크롤 지원. - */ export function useBoardPosts(activeBoardId: number | null) { const clubId = useClubId(); const isAll = activeBoardId === null; diff --git a/src/hooks/board/useCreatePost.ts b/src/hooks/board/useCreatePost.ts index 9e9eebe5..7f1b9fd1 100644 --- a/src/hooks/board/useCreatePost.ts +++ b/src/hooks/board/useCreatePost.ts @@ -7,9 +7,6 @@ import { usePostStore } from '@/stores/usePostStore'; import { toast } from '@/stores/useToastStore'; import { validatePost } from './validatePost'; -/** - * 게시글 작성 Server Action 호출 훅 - */ export function useCreatePost() { const router = useRouter(); const clubId = useClubId(); diff --git a/src/hooks/board/useDeletePost.ts b/src/hooks/board/useDeletePost.ts index d188dc08..3a57060b 100644 --- a/src/hooks/board/useDeletePost.ts +++ b/src/hooks/board/useDeletePost.ts @@ -4,13 +4,6 @@ import { deletePost } from '@/lib/actions/board'; import { useClubId } from '@/stores/useClubStore'; import { toast } from '@/stores/useToastStore'; -/** - * 게시글 삭제 Server Action 호출 훅 - * - * - clubId(store) + postId(인자)로 deletePost Server Action 호출 - * - 성공 시 게시글 목록/홈 최근 게시글 React Query 캐시 무효화 - * - 성공/실패 토스트 노출 - */ export function useDeletePost() { const clubId = useClubId(); const queryClient = useQueryClient(); diff --git a/src/hooks/board/useFileAttach.ts b/src/hooks/board/useFileAttach.ts index abcd5ef9..11e42f96 100644 --- a/src/hooks/board/useFileAttach.ts +++ b/src/hooks/board/useFileAttach.ts @@ -2,15 +2,6 @@ import { useRef, useState, useEffect } from 'react'; import { fileApi, type OwnerType } from '@/lib/apis/file'; import type { UploadFileItem } from '@/stores/usePostStore'; -/** - * 단일 파일 첨부를 관리하는 훅 - * - * - hidden input ref 제어 - * - blob preview URL 생성 및 해제 - * - 파일 교체 시 기존 blob URL 자동 revoke - * - 언마운트 시 blob URL 자동 해제 - * - upload(): 전송 시점에 presigned URL 요청 → S3 업로드 → storageKey 반환 - */ export function useFileAttach() { const inputRef = useRef(null); const [file, setFile] = useState(null); From d2b643c65608ce06e99b2049ea2ec473e92bf359 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 18:09:03 +0900 Subject: [PATCH 34/43] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=B4=20null=EC=9D=BC=20=EB=95=8C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=90=98=EB=8A=94=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoardContent.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index d292d65e..42634ee0 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -45,9 +45,16 @@ function BoardContent() {
); + if (!posts || posts.length === 0) + return ( +
+

아직 게시글이 없습니다.

+
+ ); + return (
- {(posts ?? []).map((post) => { + {posts.map((post) => { const imageFiles = post.fileUrls .filter((f) => f.contentType.startsWith('image/')) .map((f) => ({ id: f.fileId, fileName: f.fileName, fileUrl: f.fileUrl, uploaded: true })); @@ -64,7 +71,12 @@ function BoardContent() { {currentUserId === post.author.id && } -
{ e.preventDefault(); e.stopPropagation(); }}> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + >
From bb6140dd52c90b75008411b7b1ff470a0c2d69c0 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 18:09:45 +0900 Subject: [PATCH 35/43] =?UTF-8?q?fix:=20board=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=A9=94=EB=89=B4=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=EC=9D=B4=20=EC=88=98=ED=96=89=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoardContent.tsx | 6 +++++- src/components/board/PostActionMenu.tsx | 11 +++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 42634ee0..01b0c07c 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -68,7 +68,11 @@ function BoardContent() { date={formatShortDateTime(post.time)} hasAttachment={post.fileUrls.length > 0} /> - {currentUserId === post.author.id && } + {currentUserId === post.author.id && ( +
{ e.preventDefault(); e.stopPropagation(); }}> + +
+ )}
{ onDeleted?: () => void; } -/** - * 게시글 전용 수정/삭제 메뉴 - */ function PostActionMenu({ postId, onDeleted, ...rest }: PostActionMenuProps) { + const router = useRouter(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const handleEdit = () => { + router.push(`/board/edit/${postId}`); + }; + const handleDeleteSelect = (event: Event) => { event.preventDefault(); setDeleteDialogOpen(true); @@ -22,7 +25,7 @@ function PostActionMenu({ postId, onDeleted, ...rest }: PostActionMenuProps) { return ( <> - + {deleteDialogOpen ? ( Date: Wed, 8 Apr 2026 18:12:48 +0900 Subject: [PATCH 36/43] =?UTF-8?q?fix:=20prettier=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoardContent.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 01b0c07c..1a6b5524 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -69,7 +69,12 @@ function BoardContent() { hasAttachment={post.fileUrls.length > 0} /> {currentUserId === post.author.id && ( -
{ e.preventDefault(); e.stopPropagation(); }}> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + >
)} From 7e358e09c45e78c3b76aa2baf3745aa1edf9e788 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 18:21:05 +0900 Subject: [PATCH 37/43] =?UTF-8?q?style:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=8B=B5?= =?UTF-8?q?=EA=B8=80=20=EA=B0=84=20=EA=B0=84=EA=B2=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/Comment/CommentItem.tsx | 10 +++++++--- src/components/board/Comment/ReplyItem.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/board/Comment/CommentItem.tsx b/src/components/board/Comment/CommentItem.tsx index c0ea231b..5db39626 100644 --- a/src/components/board/Comment/CommentItem.tsx +++ b/src/components/board/Comment/CommentItem.tsx @@ -79,9 +79,13 @@ function CommentItem({
- {replies?.map((reply) => ( - - ))} + {replies && replies.length > 0 && ( +
+ {replies.map((reply) => ( + + ))} +
+ )} {replyOpen && (
diff --git a/src/components/board/Comment/ReplyItem.tsx b/src/components/board/Comment/ReplyItem.tsx index e4961670..6e0a3c81 100644 --- a/src/components/board/Comment/ReplyItem.tsx +++ b/src/components/board/Comment/ReplyItem.tsx @@ -28,7 +28,7 @@ function ReplyItem({ onDelete, }: ReplyItemProps) { return ( -
+
From 74e8f3df6d21bb22be1830a08bce4e4341f1f58f Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 18:24:30 +0900 Subject: [PATCH 38/43] =?UTF-8?q?fix:=20UI=20=ED=91=9C=EC=8B=9C=EC=99=80?= =?UTF-8?q?=20=EC=8A=A4=ED=86=A0=EC=96=B4=20=EC=83=81=ED=83=9C=20=EA=B0=84?= =?UTF-8?q?=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/board/write/ClientEditor.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/(private)/(main)/board/write/ClientEditor.tsx b/src/app/(private)/(main)/board/write/ClientEditor.tsx index 66ac0553..c64f7dde 100644 --- a/src/app/(private)/(main)/board/write/ClientEditor.tsx +++ b/src/app/(private)/(main)/board/write/ClientEditor.tsx @@ -27,6 +27,12 @@ export default function ClientEditor() { const activeId = board ?? activeBoardId ?? items.find((item) => item.type !== 'ALL')?.id ?? null; + useEffect(() => { + if (board === null && activeId !== null) { + setBoard(activeId); + } + }, [board, activeId, setBoard]); + return ( Date: Wed, 8 Apr 2026 23:09:34 +0900 Subject: [PATCH 39/43] =?UTF-8?q?fix:=20ALL/=EC=9C=A0=ED=9A=A8=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?ID=EA=B0=80=20board=EC=97=90=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=82=A8=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/board/write/ClientEditor.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/app/(private)/(main)/board/write/ClientEditor.tsx b/src/app/(private)/(main)/board/write/ClientEditor.tsx index c64f7dde..58412b55 100644 --- a/src/app/(private)/(main)/board/write/ClientEditor.tsx +++ b/src/app/(private)/(main)/board/write/ClientEditor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useLayoutEffect } from 'react'; import { CategorySelector, PostEditorShell } from '@/components/board'; import { useBoardList } from '@/hooks'; @@ -11,24 +11,32 @@ import { useActiveBoardId } from '@/stores/useBoardNavStore'; export default function ClientEditor() { const { data: boards } = useBoardList(); const items = boards?.map(toBoardNavItem) ?? []; + const writableItems = items.filter((item) => item.type !== 'ALL'); const activeBoardId = useActiveBoardId(); const board = usePostStore((s) => s.board); const setBoard = usePostStore((s) => s.setBoard); const reset = usePostStore((s) => s.reset); - useEffect(() => { + const isWritable = (id: number | null) => + id !== null && writableItems.some((item) => item.id === id); + + useLayoutEffect(() => { reset(); - if (activeBoardId !== null) { + if (isWritable(activeBoardId)) { setBoard(activeBoardId); } // eslint-disable-next-line react-hooks/exhaustive-deps -- 마운트 시 1회만 실행 }, []); - const activeId = board ?? activeBoardId ?? items.find((item) => item.type !== 'ALL')?.id ?? null; + const activeId = isWritable(board) + ? board + : isWritable(activeBoardId) + ? activeBoardId + : (writableItems[0]?.id ?? null); useEffect(() => { - if (board === null && activeId !== null) { + if (board !== activeId && activeId !== null) { setBoard(activeId); } }, [board, activeId, setBoard]); From d8283a221219c35459fd86f9babc68ea6438f6cf Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 18:07:10 +0900 Subject: [PATCH 40/43] =?UTF-8?q?refactor:=20submit=20->=20create/update/d?= =?UTF-8?q?elete=EB=A1=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/PostDeleteDialog.tsx | 4 ++-- src/components/layout/header/PostingActions.tsx | 13 ++++++------- src/hooks/board/useCreatePost.ts | 10 ++++++---- src/hooks/board/useDeletePost.ts | 10 ++++++---- src/hooks/board/useUpdatePost.ts | 10 ++++++---- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/components/board/PostDeleteDialog.tsx b/src/components/board/PostDeleteDialog.tsx index 12a9bc1b..9f62cdf9 100644 --- a/src/components/board/PostDeleteDialog.tsx +++ b/src/components/board/PostDeleteDialog.tsx @@ -14,11 +14,11 @@ interface PostDeleteDialogProps { * 게시글 삭제 확인 다이얼로그 */ function PostDeleteDialog({ postId, open, onOpenChange, onDeleted }: PostDeleteDialogProps) { - const { submit, isPending } = useDeletePost(); + const { deletePost, isPending } = useDeletePost(); const handleConfirm = async (event: React.MouseEvent) => { event.preventDefault(); - await submit(postId, onDeleted); + await deletePost(postId, onDeleted); onOpenChange(false); }; diff --git a/src/components/layout/header/PostingActions.tsx b/src/components/layout/header/PostingActions.tsx index d38f6228..c0c324ab 100644 --- a/src/components/layout/header/PostingActions.tsx +++ b/src/components/layout/header/PostingActions.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useRouter } from 'next/navigation'; -import { usePathname } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { Button, Icon } from '@/components/ui'; import { SendIcon } from '@/assets/icons'; @@ -15,15 +14,15 @@ function PostingActions() { const editPostId = editMatch ? Number(editMatch[1]) : null; const isEditPage = editPostId !== null; - const { submit: submitCreate, isPending: isCreating } = useCreatePost(); - const { submit: submitUpdate, isPending: isUpdating } = useUpdatePost(); + const { createPost, isPending: isCreating } = useCreatePost(); + const { updatePost, isPending: isUpdating } = useUpdatePost(); const isPending = isCreating || isUpdating; const handleSubmit = () => { - if (isEditPage && editPostId !== null) { - submitUpdate(editPostId); + if (isEditPage) { + updatePost(editPostId); } else { - submitCreate(); + createPost(); } }; diff --git a/src/hooks/board/useCreatePost.ts b/src/hooks/board/useCreatePost.ts index 7f1b9fd1..8a7759c6 100644 --- a/src/hooks/board/useCreatePost.ts +++ b/src/hooks/board/useCreatePost.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; -import { createPost } from '@/lib/actions/board'; +import { createPost as createPostApi } from '@/lib/actions/board'; import { useClubId } from '@/stores/useClubStore'; import { usePostStore } from '@/stores/usePostStore'; import { toast } from '@/stores/useToastStore'; @@ -13,7 +13,7 @@ export function useCreatePost() { const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); - const submit = async () => { + const createPost = async () => { const { board, title, content, files, getPayload, reset } = usePostStore.getState(); if (!board) { @@ -26,8 +26,10 @@ export function useCreatePost() { setIsPending(true); try { const payload = getPayload(); - const result = await createPost(clubId!, board, payload); + const result = await createPostApi(clubId!, board, payload); + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + toast({ title: '게시글이 작성되었습니다.', variant: 'success' }); reset(); router.push(`/board/${result.id}`); @@ -38,5 +40,5 @@ export function useCreatePost() { } }; - return { submit, isPending }; + return { createPost, isPending }; } diff --git a/src/hooks/board/useDeletePost.ts b/src/hooks/board/useDeletePost.ts index 3a57060b..9be5a48f 100644 --- a/src/hooks/board/useDeletePost.ts +++ b/src/hooks/board/useDeletePost.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { deletePost } from '@/lib/actions/board'; +import { deletePost as deletePostApi } from '@/lib/actions/board'; import { useClubId } from '@/stores/useClubStore'; import { toast } from '@/stores/useToastStore'; @@ -9,7 +9,7 @@ export function useDeletePost() { const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); - const submit = async (postId: number, onSuccess?: () => void) => { + const deletePost = async (postId: number, onSuccess?: () => void) => { if (!clubId) { toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); return; @@ -17,11 +17,13 @@ export function useDeletePost() { setIsPending(true); try { - await deletePost(clubId, postId); + await deletePostApi(clubId, postId); + await Promise.all([ queryClient.invalidateQueries({ queryKey: ['posts'] }), queryClient.invalidateQueries({ queryKey: ['home', 'recent-posts'] }), ]); + toast({ title: '게시글이 삭제되었습니다.', variant: 'success' }); onSuccess?.(); } catch { @@ -31,5 +33,5 @@ export function useDeletePost() { } }; - return { submit, isPending }; + return { deletePost, isPending }; } diff --git a/src/hooks/board/useUpdatePost.ts b/src/hooks/board/useUpdatePost.ts index 0df65582..096673fc 100644 --- a/src/hooks/board/useUpdatePost.ts +++ b/src/hooks/board/useUpdatePost.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; -import { updatePost } from '@/lib/actions/board'; +import { updatePost as updatePostApi } from '@/lib/actions/board'; import { resolveFilesPayload } from './resolveFilesPayload'; import { useClubId } from '@/stores/useClubStore'; import { usePostStore } from '@/stores/usePostStore'; @@ -14,7 +14,7 @@ export function useUpdatePost() { const queryClient = useQueryClient(); const [isPending, setIsPending] = useState(false); - const submit = async (postId: number) => { + const updatePost = async (postId: number) => { const { title, content, files, _snapshot, reset } = usePostStore.getState(); if (!validatePost({ clubId, title, content, files })) return; @@ -24,8 +24,10 @@ export function useUpdatePost() { setIsPending(true); try { - await updatePost(clubId!, postId, { title, content, files: filesPayload }); + await updatePostApi(clubId!, postId, { title, content, files: filesPayload }); + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + toast({ title: '게시글이 수정되었습니다.', variant: 'success' }); reset(); router.push(`/board/${postId}`); @@ -36,5 +38,5 @@ export function useUpdatePost() { } }; - return { submit, isPending }; + return { updatePost, isPending }; } From 8b34cc5b7066460b8f1e086ee473834513188e6d Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 18:08:47 +0900 Subject: [PATCH 41/43] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=ED=95=84=ED=84=B0/=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20file=20=ED=83=80=EC=9E=85=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoardContent.tsx | 19 +++++++-------- src/types/board.ts | 33 ++++----------------------- src/types/file.ts | 28 +++++++++++++++++++++++ src/types/home.ts | 14 ++---------- src/types/index.ts | 4 ++-- src/utils/shared/file.ts | 4 ++-- 6 files changed, 48 insertions(+), 54 deletions(-) create mode 100644 src/types/file.ts diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 1a6b5524..99bcefc6 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -7,10 +7,17 @@ import { useIntersectionObserver } from '@/hooks/board/useIntersectionObserver'; import { useUserId } from '@/stores/useUserStore'; import { useActiveBoardId } from '@/stores/useBoardNavStore'; import { formatShortDateTime } from '@/lib/formatTime'; +import type { FileItem } from '@/types/file'; import { PostActionMenu } from './PostActionMenu'; import { PostCard } from './PostCard'; import { BoardContentSkeleton } from './BoardContentSkeleton'; +function toDisplayImages(files: FileItem[]) { + return files + .filter((f) => f.contentType.startsWith('image/')) + .map((f) => ({ id: f.fileId, fileName: f.fileName, fileUrl: f.fileUrl, uploaded: true })); +} + function BoardContent() { const activeBoardId = useActiveBoardId(); const currentUserId = useUserId(); @@ -54,12 +61,7 @@ function BoardContent() { return (
- {posts.map((post) => { - const imageFiles = post.fileUrls - .filter((f) => f.contentType.startsWith('image/')) - .map((f) => ({ id: f.fileId, fileName: f.fileName, fileUrl: f.fileUrl, uploaded: true })); - - return ( + {posts.map((post) => ( @@ -86,13 +88,12 @@ function BoardContent() { e.stopPropagation(); }} > - +
- ); - })} + ))} {isFetchingNextPage && }
diff --git a/src/types/board.ts b/src/types/board.ts index 9866f0bf..f90cbf22 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -1,12 +1,14 @@ +import { CreatePostFile, FileItem } from './file'; + // 페이지네이션 타입은 common.ts에서 관리, 하위 호환을 위해 re-export export type { Slice, SliceSort, SlicePageable } from '@/types/common'; +// 파일 타입은 file.ts에서 관리, 하위 호환을 위해 re-export +export type { FileStatus, FileItem, DisplayFile, CreatePostFile } from '@/types/file'; export type BoardType = 'ALL' | 'NOTICE' | 'GENERAL'; export type UserRole = 'USER' | 'ADMIN'; -export type FileStatus = 'UPLOADED' | 'PENDING' | 'DELETED'; - interface BoardBase { id: number | null; type: BoardType; @@ -49,25 +51,6 @@ export interface PostListItem extends PostBase { isNew: boolean; } -/** API 응답 파일 (서버에서 받은 원본) */ -export interface FileItem { - fileId: number; - fileName: string; - fileUrl: string; - storageKey: string; - fileSize: number; - contentType: string; - status: FileStatus; -} - -/** 조회용 파일 (컴포넌트 표시 전용) */ -export interface DisplayFile { - id: string | number; - fileName: string; - fileUrl: string; - uploaded?: boolean; -} - export interface PostComment { id: number; author: PostAuthor; @@ -83,14 +66,6 @@ export interface PostDetail extends PostBase { fileUrls: FileItem[]; } -/** 게시글 작성 요청 파일 */ -export interface CreatePostFile { - fileName: string; - storageKey: string; - fileSize: number; - contentType: string; -} - /** 게시글 작성 요청 body */ export interface CreatePostBody { title: string; diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 00000000..cef4fdec --- /dev/null +++ b/src/types/file.ts @@ -0,0 +1,28 @@ +export type FileStatus = 'UPLOADED' | 'PENDING' | 'DELETED'; + +/** API 응답 파일 (서버에서 받은 원본) */ +export interface FileItem { + fileId: number; + fileName: string; + fileUrl: string; + storageKey: string; + fileSize: number; + contentType: string; + status: FileStatus; +} + +/** 조회용 파일 (컴포넌트 표시 전용) */ +export interface DisplayFile { + id: string | number; + fileName: string; + fileUrl: string; + uploaded?: boolean; +} + +/** 게시글 작성 요청 파일 */ +export interface CreatePostFile { + fileName: string; + storageKey: string; + fileSize: number; + contentType: string; +} diff --git a/src/types/home.ts b/src/types/home.ts index 2157afdd..3dff0a5e 100644 --- a/src/types/home.ts +++ b/src/types/home.ts @@ -1,4 +1,5 @@ import type { ApiResponse } from '@/types/common'; +import type { FileItem } from '@/types/file'; type Role = 'LEAD' | 'USER'; type NullableImage = string | null; @@ -63,16 +64,6 @@ interface MonthlySchedule { type: string; } -interface FileAttachment { - fileId: number; - fileName: string; - fileUrl: string; - storageKey: string; - fileSize: number; - contentType: string; - status: string; -} - type PostAuthor = UserSummary; interface RecentPost { @@ -83,7 +74,7 @@ interface RecentPost { time: string; commentCount: number; likeCount: number; - fileUrls: FileAttachment[]; + fileUrls: FileItem[]; isNew: boolean; } @@ -138,7 +129,6 @@ export type { UnreadNotice, RecentNotice, MonthlySchedule, - FileAttachment, PostAuthor, RecentPost, SortInfo, diff --git a/src/types/index.ts b/src/types/index.ts index d9aa4b65..8f053b35 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,16 +2,16 @@ export type { ApiResponse, MutationCallbacks } from './common'; export type { ClubInfo, - UserInfo, MyInfo, HomeDashboard, HomeDashboardResponse, UnreadNotice, RecentNotice, MonthlySchedule, - FileAttachment, PostAuthor, RecentPost, PageData, } from './home'; export type { Club } from './club'; + +export type { FileStatus, FileItem, DisplayFile, CreatePostFile } from './file'; diff --git a/src/utils/shared/file.ts b/src/utils/shared/file.ts index 8453c296..a1319766 100644 --- a/src/utils/shared/file.ts +++ b/src/utils/shared/file.ts @@ -1,7 +1,7 @@ -import type { FileAttachment } from '@/types/home'; +import type { FileItem } from '@/types/file'; import type { UploadFileItem } from '@/stores/usePostStore'; -export function fileAttachmentToFileItem(file: FileAttachment): UploadFileItem { +export function fileAttachmentToFileItem(file: FileItem): UploadFileItem { return { id: String(file.fileId), fileName: file.fileName, From 779650750414bb297deae7d67921a13253956c5e Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 18:09:28 +0900 Subject: [PATCH 42/43] =?UTF-8?q?chore:=20next/navigation=20import=20?= =?UTF-8?q?=EA=B5=AC=EB=AC=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/header/DefaultActions.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/layout/header/DefaultActions.tsx b/src/components/layout/header/DefaultActions.tsx index ad624bce..c671c475 100644 --- a/src/components/layout/header/DefaultActions.tsx +++ b/src/components/layout/header/DefaultActions.tsx @@ -1,8 +1,7 @@ 'use client'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { usePathname } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; import { Button } from '@/components/ui'; From 8d3773b2c8b3241f9ed2fbe218c75198113fa463 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 18:11:04 +0900 Subject: [PATCH 43/43] =?UTF-8?q?fix:=20prettier=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/board/BoardContent.tsx | 62 +++++++++++++-------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 99bcefc6..cacea9b0 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -62,37 +62,37 @@ function BoardContent() { return (
{posts.map((post) => ( - - - - 0} - /> - {currentUserId === post.author.id && ( -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - -
- )} -
- -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - -
- -
- + + + + 0} + /> + {currentUserId === post.author.id && ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+ )} +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+ +
+ ))} {isFetchingNextPage && }