@@ -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,
};
}