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 25a9804e..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,7 @@ 'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { Divider } from '@/components/ui'; import { PostCard, @@ -11,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'; @@ -20,7 +23,13 @@ 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 @@ -48,15 +57,16 @@ function PostDetailContent({ post }: PostDetailContentProps) { date={formatShortDateTime(post.time)} hasAttachment={post.fileUrls.length > 0} /> - {isPostAuthor && } + {isPostAuthor && ( + router.push(`/board/edit/${post.id}`)} + onDeleted={() => router.push('/board')} + /> + )} - + diff --git a/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx b/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx new file mode 100644 index 00000000..5bb7f511 --- /dev/null +++ b/src/app/(private)/(main)/board/edit/[id]/EditClientEditor.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +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'; + +interface EditClientEditorProps { + post: PostDetail; +} + +function EditClientEditor({ post }: EditClientEditorProps) { + const initializedRef = useRef(false); + + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + usePostStore.getState().initFromDetail(post); + }, [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 ( + } + /> + ); +} + +export { EditClientEditor }; diff --git a/src/app/(private)/(main)/board/edit/[id]/page.tsx b/src/app/(private)/(main)/board/edit/[id]/page.tsx new file mode 100644 index 00000000..4e8fcf18 --- /dev/null +++ b/src/app/(private)/(main)/board/edit/[id]/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from 'next/navigation'; + +import { boardServerApi } from '@/lib/apis/board.server'; +import { EditClientEditor } from './EditClientEditor'; + +interface PostEditPageProps { + params: Promise<{ id: string }>; +} + +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', postId).catch(() => null); + + if (!response?.data) { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/(private)/(main)/board/write/ClientEditor.tsx b/src/app/(private)/(main)/board/write/ClientEditor.tsx index a6104027..58412b55 100644 --- a/src/app/(private)/(main)/board/write/ClientEditor.tsx +++ b/src/app/(private)/(main)/board/write/ClientEditor.tsx @@ -1,41 +1,50 @@ 'use client'; -import dynamic from 'next/dynamic'; -import { useEffect } from 'react'; +import { useEffect, useLayoutEffect } from 'react'; -import { TitleInput, CategorySelector } from '@/components/board'; +import { CategorySelector, PostEditorShell } from '@/components/board'; import { useBoardList } from '@/hooks'; import { toBoardNavItem } from '@/lib/board'; import { usePostStore } from '@/stores/usePostStore'; - -const Editor = dynamic(() => import('@/components/board/Editor'), { ssr: false }); +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); - const defaultId = items.find((item) => item.type === 'ALL')?.id ?? items[0]?.id ?? null; + const isWritable = (id: number | null) => + id !== null && writableItems.some((item) => item.id === id); - useEffect(() => { - if (board === null && defaultId !== null) { - setBoard(defaultId); + useLayoutEffect(() => { + reset(); + if (isWritable(activeBoardId)) { + setBoard(activeBoardId); } - }, [board, defaultId, setBoard]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- 마운트 시 1회만 실행 + }, []); + + const activeId = isWritable(board) + ? board + : isWritable(activeBoardId) + ? activeBoardId + : (writableItems[0]?.id ?? null); - const activeId = board ?? defaultId; + useEffect(() => { + if (board !== activeId && activeId !== null) { + setBoard(activeId); + } + }, [board, activeId, setBoard]); return ( -
- -
- -
- -
-
-
+ } + /> ); } diff --git a/src/app/globals.css b/src/app/globals.css index 92f7dc87..0928be80 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 flex-1; } /* 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/ActionMenu.tsx b/src/components/board/ActionMenu.tsx new file mode 100644 index 00000000..8dce1aec --- /dev/null +++ b/src/components/board/ActionMenu.tsx @@ -0,0 +1,64 @@ +'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 ActionMenuProps { + className?: string; + onEdit?: () => void; + onDeleteSelect?: (event: Event) => void; + triggerVariant?: ButtonProps['variant']; + triggerSize?: ButtonProps['size']; + triggerClassName?: string; +} + +/** + * 수정/삭제 드롭다운 메뉴 + * + * 외부 핸들러에 동작을 위임합니다. post 삭제처럼 확인 다이얼로그 + API 호출이 + * 내장된 동작이 필요하면 `PostActionMenu`를 사용하세요. + */ +function ActionMenu({ + className, + onEdit, + onDeleteSelect, + triggerVariant = 'tertiary', + triggerSize = 'icon-md', + triggerClassName, +}: ActionMenuProps) { + return ( + + + + + + 수정 + + + 삭제 + + + + ); +} + +export { ActionMenu, type ActionMenuProps }; diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 20e18e51..cacea9b0 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(); @@ -45,22 +52,44 @@ function BoardContent() { ); + if (!posts || posts.length === 0) + return ( +
+

아직 게시글이 없습니다.

+
+ ); + return (
- {(posts ?? []).map((post) => ( + {posts.map((post) => ( 0} /> {currentUserId === post.author.id && ( - e.preventDefault()} /> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
)}
- + +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
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/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/Comment/CommentItem.tsx b/src/components/board/Comment/CommentItem.tsx index 03aee7ab..5db39626 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,19 +69,23 @@ function CommentItem({ {isAuthor && ( - )} - {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 cea65e64..6e0a3c81 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; @@ -28,7 +28,7 @@ function ReplyItem({ onDelete, }: ReplyItemProps) { return ( -
+
@@ -43,11 +43,11 @@ function ReplyItem({

{date}

{isAuthor && ( - )}
diff --git a/src/components/board/Editor/index.tsx b/src/components/board/Editor/index.tsx index 0def6803..c05c1b68 100644 --- a/src/components/board/Editor/index.tsx +++ b/src/components/board/Editor/index.tsx @@ -46,33 +46,21 @@ const floatingMenuTippyOptions = { * - 이미지 붙여넣기 (Ctrl+V) 및 드래그앤드롭 */ -export default function Editor() { +interface EditorProps { + initialContent?: string; +} + +export default function Editor({ initialContent }: EditorProps = {}) { const { imageInputRef, fileInputRef, processFiles, picker, files, handlers } = useFileUpload(); const { editor, showSlashMenu, closeSlashMenu, containerRef } = usePostEditor({ processFiles, + initialContent, }); - 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를 통해 트리거 */} +
- {/* 게시글 하단 첨부 영역 */} -
- - -
+ {/* 게시글 하단 첨부 영역 */} +
+ +
); diff --git a/src/components/board/Editor/usePostEditor.ts b/src/components/board/Editor/usePostEditor.ts index 5eb8d042..8478b810 100644 --- a/src/components/board/Editor/usePostEditor.ts +++ b/src/components/board/Editor/usePostEditor.ts @@ -7,10 +7,13 @@ import { editorExtensions } from './extensions'; interface UsePostEditorOptions { processFiles?: (files: File[]) => void; + initialContent?: string; } -export function usePostEditor({ processFiles }: UsePostEditorOptions = {}) { +export function usePostEditor({ processFiles, initialContent }: UsePostEditorOptions = {}) { const setContent = usePostStore((state) => state.setContent); + // 마운트 시점에 한 번만 초기 content 고정 (수정 페이지용) + const [initialContentValue] = useState(() => initialContent ?? ''); const [showSlashMenu, setShowSlashMenu] = useState(false); // ref로 최신 상태 유지 → useEditor 내부 handleKeyDown stale closure 방지 const showSlashMenuRef = useRef(false); @@ -32,7 +35,7 @@ export function usePostEditor({ processFiles }: UsePostEditorOptions = {}) { const editor = useEditor({ extensions: editorExtensions, - content: '', + content: initialContentValue, onUpdate: ({ editor }) => { setContent(editor.getHTML()); diff --git a/src/components/board/PostActionMenu.tsx b/src/components/board/PostActionMenu.tsx index 9fc07ffa..55ab22b2 100644 --- a/src/components/board/PostActionMenu.tsx +++ b/src/components/board/PostActionMenu.tsx @@ -1,59 +1,41 @@ '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 { useRouter } from 'next/navigation'; +import { ActionMenu, type ActionMenuProps } from './ActionMenu'; +import { PostDeleteDialog } from './PostDeleteDialog'; + +interface PostActionMenuProps extends Omit { + postId: number; + onDeleted?: () => void; } -function PostActionMenu({ - className, - onEdit, - onDelete, - onClick, - triggerVariant = 'tertiary', - triggerSize = 'icon-md', - triggerClassName, -}: PostActionMenuProps) { +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); + }; + return ( - - - - - - 수정 - - - 삭제 - - - + <> + + + {deleteDialogOpen ? ( + + ) : null} + ); } 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 deleted file mode 100644 index 83242a14..00000000 --- a/src/components/board/PostCard/PostCardContent.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import Image from 'next/image'; -import { NewIcon } from '@/assets/icons'; -import { cn } from '@/lib/cn'; - -interface PostCardContentProps { - className?: string; - title: string; - content: string; - isNew?: boolean; - expandable?: boolean; - variant?: 'list' | 'detail'; -} - -function PostCardContent({ - className, - title, - content, - isNew, - expandable = true, - variant = 'list', -}: PostCardContentProps) { - const contentRef = useRef(null); - const [isClamped, setIsClamped] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - - useEffect(() => { - const el = contentRef.current; - if (!el || !expandable) return; - - const check = () => { - setIsClamped(el.scrollHeight > el.clientHeight); - }; - - check(); - - const ro = new ResizeObserver(check); - ro.observe(el); - return () => ro.disconnect(); - }, [content, expandable]); - - return ( -
-
-

- {title} -

- {isNew && ( - <> - - 새 글 - - )} -
-

- {content} -

- {expandable && isClamped && !isExpanded && ( - - )} -
- ); -} - -export { PostCardContent, type PostCardContentProps }; 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/PostCardListContent.tsx b/src/components/board/PostCard/PostCardListContent.tsx new file mode 100644 index 00000000..f456be99 --- /dev/null +++ b/src/components/board/PostCard/PostCardListContent.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { cn } from '@/lib/cn'; +import { useLineClamp } from '@/hooks/useLineClamp'; + +import { PostCardTitle } from './PostCardTitle'; +import { ExpandButton } from './ExpandButton'; + +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 ( +
+ +

+ {plainContent} +

+ {expandable && isClamped && !isExpanded && ( + setIsExpanded(true)} /> + )} +
+ ); +} + +export { PostCardListContent, type PostCardListContentProps }; 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/components/board/PostCard/index.tsx b/src/components/board/PostCard/index.tsx index be8a2c53..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 { PostCardContent } from './PostCardContent'; +import { PostCardListContent } from './PostCardListContent'; +import { PostCardDetailContent } from './PostCardDetailContent'; import { PostCardActions, type PostCardActionsProps } from './PostCardActions'; function PostCardRoot({ className, children, ...props }: React.ComponentProps<'article'>) { @@ -45,7 +46,8 @@ const PostCard = { Root: PostCardRoot, Header: PostCardHeader, Author: PostAuthorInfo, - Content: PostCardContent, + ListContent: PostCardListContent, + DetailContent: PostCardDetailContent, Images: PostCardImages, Actions: PostCardActions, }; diff --git a/src/components/board/PostDeleteDialog.tsx b/src/components/board/PostDeleteDialog.tsx new file mode 100644 index 00000000..9f62cdf9 --- /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 { deletePost, isPending } = useDeletePost(); + + const handleConfirm = async (event: React.MouseEvent) => { + event.preventDefault(); + await deletePost(postId, onDeleted); + onOpenChange(false); + }; + + return ( + + + 삭제 + + 취소 + + ); +} + +export { PostDeleteDialog, type PostDeleteDialogProps }; 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/board/PostEditorShell.tsx b/src/components/board/PostEditorShell.tsx new file mode 100644 index 00000000..5e46bac9 --- /dev/null +++ b/src/components/board/PostEditorShell.tsx @@ -0,0 +1,67 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import type { ReactNode } from 'react'; + +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 { + header: ReactNode; + initialContent?: string; + align?: 'start' | 'center'; +} + +/** + * 게시글 작성/수정 페이지 공통 레이아웃 Shell + */ +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 = 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 ( +
+ {header} +
+ +
+ +
+
+ + { + if (!isOpen) onCancel(); + }} + title="변경 사항이 저장되지 않았어요" + description={'지금 나가면 작성 중인 내용이 사라집니다.\n계속하시겠어요?'} + > + 나가기 + 계속 작성 + +
+ ); +} + +export { PostEditorShell, type PostEditorShellProps }; diff --git a/src/components/board/index.ts b/src/components/board/index.ts index d3de6035..80228fb9 100644 --- a/src/components/board/index.ts +++ b/src/components/board/index.ts @@ -9,12 +9,14 @@ 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'; export { ImageList, type ImageListProps } from './ImageList'; export { TitleInput, type TitleInputProps } from './TitleInput'; export { CategorySelector, type CategorySelectorProps } from './CategorySelector'; +export { PostEditorShell, type PostEditorShellProps } from './PostEditorShell'; export { BoardNavSkeleton } from './BoardNavSkeleton'; export { BoardContentSkeleton } from './BoardContentSkeleton'; export { PostDetailSkeleton } from './PostDetailSkeleton'; diff --git a/src/components/home/HomeBoardContent.tsx b/src/components/home/HomeBoardContent.tsx index c2561f66..739e0120 100644 --- a/src/components/home/HomeBoardContent.tsx +++ b/src/components/home/HomeBoardContent.tsx @@ -34,9 +34,9 @@ function HomeBoardContent() { dateTime={post.time} hasAttachment={hasAttachment} /> - {isMyPost && e.preventDefault()} />} + {isMyPost && } - + diff --git a/src/components/layout/header/DefaultActions.tsx b/src/components/layout/header/DefaultActions.tsx new file mode 100644 index 00000000..c671c475 --- /dev/null +++ b/src/components/layout/header/DefaultActions.tsx @@ -0,0 +1,73 @@ +'use client'; + +import Image from 'next/image'; +import { usePathname, useRouter } 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 e7ca3412..09df55df 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -2,10 +2,12 @@ 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 { MenuIcon, EditIcon, SendIcon, ExitToAppIcon, AvatarIcon, LogoIcon } from '@/assets/icons'; + +import { MenuIcon, LogoIcon } from '@/assets/icons'; + +import { PostingActions } from './PostingActions'; +import { DefaultActions } from './DefaultActions'; interface HeaderProps { isMain?: boolean; @@ -23,14 +25,14 @@ 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 navItems = [ - { id: 'board', label: '게시판', href: '/board' }, - { id: 'attendance', label: '출석', href: '/attendance' }, - ]; - const isWritePage = pathname.includes('/write'); + const isPostingPage = pathname.includes('/write') || /^\/board\/edit\/\d+$/.test(pathname); return ( <> @@ -62,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 && ( -
- {isWritePage ? ( - <> - - - - ) : ( - <> - {pathname.startsWith('/board') && ( - - )} - - - - )} -
- )} + {isMain && (isPostingPage ? : )} ); diff --git a/src/components/layout/header/PostingActions.tsx b/src/components/layout/header/PostingActions.tsx new file mode 100644 index 00000000..c0c324ab --- /dev/null +++ b/src/components/layout/header/PostingActions.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { usePathname, useRouter } 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 { createPost, isPending: isCreating } = useCreatePost(); + const { updatePost, isPending: isUpdating } = useUpdatePost(); + const isPending = isCreating || isUpdating; + + const handleSubmit = () => { + if (isEditPage) { + updatePost(editPostId); + } else { + createPost(); + } + }; + + return ( +
+ + +
+ ); +} + +export { PostingActions }; 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/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 new file mode 100644 index 00000000..8a7759c6 --- /dev/null +++ b/src/hooks/board/useCreatePost.ts @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; +import { createPost as createPostApi } from '@/lib/actions/board'; +import { useClubId } from '@/stores/useClubStore'; +import { usePostStore } from '@/stores/usePostStore'; +import { toast } from '@/stores/useToastStore'; +import { validatePost } from './validatePost'; + +export function useCreatePost() { + const router = useRouter(); + const clubId = useClubId(); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + + const createPost = async () => { + const { board, title, content, files, getPayload, reset } = usePostStore.getState(); + + if (!board) { + toast({ title: '게시판을 선택해주세요.', variant: 'error' }); + return; + } + + if (!validatePost({ clubId, title, content, files })) return; + + setIsPending(true); + try { + const payload = getPayload(); + const result = await createPostApi(clubId!, board, payload); + + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + + toast({ title: '게시글이 작성되었습니다.', variant: 'success' }); + reset(); + router.push(`/board/${result.id}`); + } catch { + toast({ title: '게시글 작성에 실패했습니다.', variant: 'error' }); + } finally { + setIsPending(false); + } + }; + + return { createPost, isPending }; +} diff --git a/src/hooks/board/useDeletePost.ts b/src/hooks/board/useDeletePost.ts new file mode 100644 index 00000000..9be5a48f --- /dev/null +++ b/src/hooks/board/useDeletePost.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { deletePost as deletePostApi } from '@/lib/actions/board'; +import { useClubId } from '@/stores/useClubStore'; +import { toast } from '@/stores/useToastStore'; + +export function useDeletePost() { + const clubId = useClubId(); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + + const deletePost = async (postId: number, onSuccess?: () => void) => { + if (!clubId) { + toast({ title: '클럽 정보를 불러올 수 없습니다.', variant: 'error' }); + return; + } + + setIsPending(true); + try { + await deletePostApi(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 { deletePost, isPending }; +} diff --git a/src/hooks/board/useFileAttach.ts b/src/hooks/board/useFileAttach.ts index 4e2ca193..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); @@ -36,6 +27,8 @@ export function useFileAttach() { file: selected, fileName: selected.name, fileUrl: URL.createObjectURL(selected), + fileSize: selected.size, + contentType: selected.type || 'application/octet-stream', storageKey: '', uploaded: false, }); diff --git a/src/hooks/board/useUpdatePost.ts b/src/hooks/board/useUpdatePost.ts new file mode 100644 index 00000000..096673fc --- /dev/null +++ b/src/hooks/board/useUpdatePost.ts @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; +import { updatePost as updatePostApi } 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'; + +export function useUpdatePost() { + const router = useRouter(); + const clubId = useClubId(); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + + const updatePost = async (postId: number) => { + 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 updatePostApi(clubId!, postId, { title, content, files: filesPayload }); + + await queryClient.invalidateQueries({ queryKey: ['posts'] }); + + toast({ title: '게시글이 수정되었습니다.', variant: 'success' }); + reset(); + router.push(`/board/${postId}`); + } catch { + toast({ title: '게시글 수정에 실패했습니다.', variant: 'error' }); + } finally { + setIsPending(false); + } + }; + + return { updatePost, isPending }; +} diff --git a/src/hooks/board/validatePost.ts b/src/hooks/board/validatePost.ts new file mode 100644 index 00000000..b2e42137 --- /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/board/isHtmlEmpty'; + +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; +} 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) { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 93f502a0..63130fae 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,5 +8,10 @@ export { useScrollIntoView } from './useScrollIntoView'; 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 { 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/useFileUpload.ts b/src/hooks/useFileUpload.ts index fad01447..0dab1a56 100644 --- a/src/hooks/useFileUpload.ts +++ b/src/hooks/useFileUpload.ts @@ -177,6 +177,8 @@ export function useFileUpload(ownerType: OwnerType = 'POST') { fileName: file.name, fileUrl: URL.createObjectURL(file), storageKey: '', + fileSize: file.size, + contentType: file.type || 'application/octet-stream', uploaded: false, })); 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 }; 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 }; diff --git a/src/lib/actions/board.ts b/src/lib/actions/board.ts index eaaeb62e..83060320 100644 --- a/src/lib/actions/board.ts +++ b/src/lib/actions/board.ts @@ -2,8 +2,27 @@ import { revalidatePath } from 'next/cache'; import { boardServerApi } from '@/lib/apis/board.server'; +import type { CreatePostBody, UpdatePostBody } 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'); + 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; +} + +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 0ea0aed3..a2e452d7 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, PostDetail } from '@/types/board'; +import type { + Board, + CreatePostBody, + CreatePostData, + PostDetail, + UpdatePostBody, + UpdatePostData, +} from '@/types/board'; export const boardServerApi = { /** 게시판 목록 조회 (RSC) — 거의 변하지 않으므로 30분 캐싱 */ @@ -18,4 +25,16 @@ 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), + + /** 게시글 수정 (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}`), }; diff --git a/src/stores/usePostStore.ts b/src/stores/usePostStore.ts index 11d7814c..b43a148a 100644 --- a/src/stores/usePostStore.ts +++ b/src/stores/usePostStore.ts @@ -1,13 +1,24 @@ import { create } from 'zustand'; import { combine, devtools } from 'zustand/middleware'; +import type { PostDetail } from '@/types/board'; + export interface UploadFileItem { id: string; file?: File; fileName: string; fileUrl: string; storageKey: string; + fileSize: number; + contentType: string; uploaded: boolean; + isExisting?: boolean; +} + +interface Snapshot { + title: string; + content: string; + fileIds: string[]; } const initialState = { @@ -21,6 +32,7 @@ const initialState = { content: '', files: [] as UploadFileItem[], status: 'DRAFT' as 'DRAFT' | 'PUBLISHED', + _snapshot: null as Snapshot | null, }; export type PostState = typeof initialState; @@ -78,17 +90,49 @@ export const usePostStore = create( set(initialState, false, 'reset'); }, - getPayload: () => { + 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, + 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, + isExisting: true, + })), + _snapshot: { title: post.title, content: post.content, fileIds }, + }, + false, + 'initFromDetail', + ); + }, + + getPayload: (isEdit = false) => { const state = get(); return { title: state.title, content: state.content, - category: state.category, - studyName: state.studyName, - week: state.week, - part: state.part, - generationNumber: state.generationNumber, - files: state.files.filter((f) => f.uploaded).map(({ storageKey }) => storageKey), + files: state.files + .filter((f) => f.uploaded && !(isEdit && f.isExisting)) + .map((f) => ({ + fileName: f.fileName, + storageKey: f.storageKey, + fileSize: f.fileSize, + contentType: f.contentType, + })), }; }, })), diff --git a/src/types/board.ts b/src/types/board.ts index 7d9f4cee..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; @@ -45,29 +47,10 @@ interface PostBase { } export interface PostListItem extends PostBase { - hasFile: boolean; + fileUrls: FileItem[]; 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; @@ -78,10 +61,35 @@ export interface PostComment { } export interface PostDetail extends PostBase { + isNew?: boolean; comments: PostComment[]; fileUrls: FileItem[]; } +/** 게시글 작성 요청 body */ +export interface CreatePostBody { + title: string; + content: string; + files: CreatePostFile[]; +} + +/** 게시글 작성 응답 data */ +export interface CreatePostData { + id: number; +} + +/** 게시글 수정 요청 body — files: null=변경 없음, []=전체 삭제, 배열=교체 */ +export interface UpdatePostBody { + title: string; + content: string; + files: CreatePostFile[] | null; +} + +/** 게시글 수정 응답 data */ +export interface UpdatePostData { + id: number; +} + /** mapComment 변환 결과 (UI 표시용) */ export interface MappedComment { id: number; 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/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; +} diff --git a/src/utils/shared/file.ts b/src/utils/shared/file.ts index 7c23ecb3..a1319766 100644 --- a/src/utils/shared/file.ts +++ b/src/utils/shared/file.ts @@ -1,12 +1,14 @@ -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, fileUrl: file.fileUrl, storageKey: file.storageKey, + fileSize: file.fileSize, + contentType: file.contentType, uploaded: true, }; }