From bcbcafb58f32187a1a2945ebede84b48c1124121 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 12 Jun 2026 19:13:53 +0900 Subject: [PATCH 01/13] =?UTF-8?q?:sparkles:=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20API=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커뮤니티 게시판 요청/응답 타입 추가 - 게시글 CRUD 및 임시저장 API 함수 추가 - 커뮤니티 에러/상태 상수 및 임시저장 스토리지 키 추가 - feature index에서 커뮤니티 도메인 기반 구조 export - 게시글 상세 및 임시저장 게시글용 react-query key 추가 `#53` --- src/features/community/api/boards.ts | 43 ++++++++++++++++++++ src/features/community/index.ts | 19 +++++++++ src/features/community/lib/constants.ts | 24 ++++++++++++ src/features/community/model/types.ts | 52 +++++++++++++++++++++++++ src/shared/lib/react-query/queryKey.ts | 4 ++ 5 files changed, 142 insertions(+) create mode 100644 src/features/community/api/boards.ts create mode 100644 src/features/community/index.ts create mode 100644 src/features/community/lib/constants.ts create mode 100644 src/features/community/model/types.ts diff --git a/src/features/community/api/boards.ts b/src/features/community/api/boards.ts new file mode 100644 index 0000000..a9bca1a --- /dev/null +++ b/src/features/community/api/boards.ts @@ -0,0 +1,43 @@ +import { apiClient } from '@/shared/api/axios'; + +import type { + BoardDetailResponse, + CreateBoardRequest, + CreateBoardResponse, + DeleteBoardResponse, + TempSavedBoardResponse, + TempSaveBoardRequest, + TempSaveBoardResponse, + UpdateBoardRequest, + UpdateBoardResponse, +} from '../model/types'; + +export async function createBoard(payload: CreateBoardRequest): Promise { + const response = await apiClient.post('/boards', payload); + return response.data; +} + +export async function tempSaveBoard(payload: TempSaveBoardRequest): Promise { + const response = await apiClient.post('/boards/temp-save', payload); + return response.data; +} + +export async function getTempSavedBoard(sessionKey: string): Promise { + const response = await apiClient.get(`/boards/temp-save/${sessionKey}`); + return response.data; +} + +export async function getBoardDetail(boardId: number): Promise { + const response = await apiClient.get(`/boards/${boardId}`); + return response.data; +} + +export async function updateBoard(boardId: number, payload: UpdateBoardRequest): Promise { + const response = await apiClient.patch(`/boards/${boardId}`, payload); + return response.data; +} + +export async function deleteBoard(boardId: number): Promise { + const response = await apiClient.delete(`/boards/${boardId}`); + return response.data; +} diff --git a/src/features/community/index.ts b/src/features/community/index.ts new file mode 100644 index 0000000..8844028 --- /dev/null +++ b/src/features/community/index.ts @@ -0,0 +1,19 @@ +export { createBoard, deleteBoard, getBoardDetail, getTempSavedBoard, tempSaveBoard, updateBoard } from './api/boards'; +export { + BOARD_DETAIL_STATUS_MESSAGES, + BOARD_DRAFT_STATUS_MESSAGES, + BOARD_MUTATION_STATUS_MESSAGES, + COMMUNITY_DRAFT_SESSION_KEY, +} from './lib/constants'; +export type { + BoardDetailResponse, + BoardPayload, + CreateBoardRequest, + CreateBoardResponse, + DeleteBoardResponse, + TempSavedBoardResponse, + TempSaveBoardRequest, + TempSaveBoardResponse, + UpdateBoardRequest, + UpdateBoardResponse, +} from './model/types'; diff --git a/src/features/community/lib/constants.ts b/src/features/community/lib/constants.ts new file mode 100644 index 0000000..941f228 --- /dev/null +++ b/src/features/community/lib/constants.ts @@ -0,0 +1,24 @@ +export const COMMUNITY_DRAFT_SESSION_KEY = 'community-board-draft-session-key'; + +export const BOARD_MUTATION_STATUS_MESSAGES: Partial> = { + 400: '입력값을 다시 확인해주세요.', + 401: '로그인이 필요합니다. 다시 로그인해주세요.', + 403: '게시글을 처리할 권한이 없습니다.', + 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', +}; + +export const BOARD_DETAIL_STATUS_MESSAGES: Partial> = { + 400: '잘못된 게시글 요청입니다.', + 401: '로그인이 필요합니다. 다시 로그인해주세요.', + 403: '게시글을 조회할 권한이 없습니다.', + 404: '게시글을 찾을 수 없습니다.', + 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', +}; + +export const BOARD_DRAFT_STATUS_MESSAGES: Partial> = { + 400: '임시 저장 요청을 처리할 수 없습니다.', + 401: '로그인이 필요합니다. 다시 로그인해주세요.', + 403: '임시 저장 게시글을 처리할 권한이 없습니다.', + 404: '임시 저장된 게시글을 찾을 수 없습니다.', + 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', +}; diff --git a/src/features/community/model/types.ts b/src/features/community/model/types.ts new file mode 100644 index 0000000..d31dd35 --- /dev/null +++ b/src/features/community/model/types.ts @@ -0,0 +1,52 @@ +export interface BoardPayload { + boardTitle: string; + boardContent: string; + imageFileUrls: string[]; +} + +export type CreateBoardRequest = BoardPayload; + +export interface CreateBoardResponse { + message: string; + boardId: number; +} + +export interface TempSaveBoardRequest { + boardTitle?: string; + boardContent?: string; + imageFileUrl?: string; +} + +export interface TempSaveBoardResponse { + message: string; + sessionKey: string; +} + +export interface TempSavedBoardResponse { + message: string; + boardTitle: string; + boardContent: string; + imageFileUrl: string | null; +} + +export interface BoardDetailResponse { + message: string; + boardId: number; + boardTitle: string; + boardContent: string; + imageFileUrls: string[]; + nickname: string; + viewCount: number; + boardCreatedAt: string; + modifiedAt: string; +} + +export type UpdateBoardRequest = BoardPayload; + +export interface UpdateBoardResponse { + message: string; +} + +export interface DeleteBoardResponse { + message: string; +} diff --git a/src/shared/lib/react-query/queryKey.ts b/src/shared/lib/react-query/queryKey.ts index 44d7cb0..03e14c9 100644 --- a/src/shared/lib/react-query/queryKey.ts +++ b/src/shared/lib/react-query/queryKey.ts @@ -1,4 +1,8 @@ export const queryKeys = { + boards: { + detail: (boardId: number) => ['boards', boardId, 'detail'] as const, + tempSaved: (sessionKey: string) => ['boards', 'temp-save', sessionKey] as const, + }, pets: { list: (params?: { page?: number; size?: number; sort?: string }) => ['pets', 'list', params?.page ?? 0, params?.size ?? 10, params?.sort ?? 'registrationCreatedAt,desc'] as const, From 486e8a041be14f498f42fffb978ef717a4e79b78 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 12 Jun 2026 19:19:43 +0900 Subject: [PATCH 02/13] =?UTF-8?q?:sparkles:=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=ED=9B=85=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=94=94=ED=84=B0=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커뮤니티 게시판 query/mutation 훅 추가 - 공통 게시글 에디터 폼 상태 및 유효성 검사 추가 - 임시저장 세션 키 유지를 위한 localStorage 헬퍼 추가 - useBoardEditorForm에 게시글 작성/수정/임시저장 에디터 플로우 구현 - feature index에서 커뮤니티 훅 및 폼 유틸리티 export `#53` --- src/features/community/index.ts | 15 + src/features/community/lib/storage.ts | 26 ++ src/features/community/lib/validation.ts | 30 ++ .../community/model/useBoardDetail.ts | 14 + .../community/model/useBoardEditorForm.ts | 324 ++++++++++++++++++ .../community/model/useCreateBoard.ts | 10 + .../community/model/useDeleteBoard.ts | 22 ++ .../community/model/useTempSaveBoard.ts | 10 + .../community/model/useTempSavedBoard.ts | 14 + .../community/model/useUpdateBoard.ts | 23 ++ 10 files changed, 488 insertions(+) create mode 100644 src/features/community/lib/storage.ts create mode 100644 src/features/community/lib/validation.ts create mode 100644 src/features/community/model/useBoardDetail.ts create mode 100644 src/features/community/model/useBoardEditorForm.ts create mode 100644 src/features/community/model/useCreateBoard.ts create mode 100644 src/features/community/model/useDeleteBoard.ts create mode 100644 src/features/community/model/useTempSaveBoard.ts create mode 100644 src/features/community/model/useTempSavedBoard.ts create mode 100644 src/features/community/model/useUpdateBoard.ts diff --git a/src/features/community/index.ts b/src/features/community/index.ts index 8844028..d8d5d12 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -5,6 +5,19 @@ export { BOARD_MUTATION_STATUS_MESSAGES, COMMUNITY_DRAFT_SESSION_KEY, } from './lib/constants'; +export { + clearStoredBoardDraftSessionKey, + getStoredBoardDraftSessionKey, + setStoredBoardDraftSessionKey, +} from './lib/storage'; +export { INITIAL_BOARD_EDITOR_FORM_STATE, validateBoardEditorForm } from './lib/validation'; +export { useBoardDetail } from './model/useBoardDetail'; +export { useBoardEditorForm } from './model/useBoardEditorForm'; +export { useCreateBoard } from './model/useCreateBoard'; +export { useDeleteBoard } from './model/useDeleteBoard'; +export { useTempSaveBoard } from './model/useTempSaveBoard'; +export { useTempSavedBoard } from './model/useTempSavedBoard'; +export { useUpdateBoard } from './model/useUpdateBoard'; export type { BoardDetailResponse, BoardPayload, @@ -17,3 +30,5 @@ export type { UpdateBoardRequest, UpdateBoardResponse, } from './model/types'; +export type { BoardEditorFormErrors, BoardEditorFormState } from './lib/validation'; +export type { BoardEditorMode, UseBoardEditorFormOptions } from './model/useBoardEditorForm'; diff --git a/src/features/community/lib/storage.ts b/src/features/community/lib/storage.ts new file mode 100644 index 0000000..2fe626a --- /dev/null +++ b/src/features/community/lib/storage.ts @@ -0,0 +1,26 @@ +import { COMMUNITY_DRAFT_SESSION_KEY } from './constants'; + +export function getStoredBoardDraftSessionKey(): string | null { + if (typeof window === 'undefined') { + return null; + } + + const value = window.localStorage.getItem(COMMUNITY_DRAFT_SESSION_KEY); + return value?.trim() ? value : null; +} + +export function setStoredBoardDraftSessionKey(sessionKey: string) { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(COMMUNITY_DRAFT_SESSION_KEY, sessionKey); +} + +export function clearStoredBoardDraftSessionKey() { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.removeItem(COMMUNITY_DRAFT_SESSION_KEY); +} diff --git a/src/features/community/lib/validation.ts b/src/features/community/lib/validation.ts new file mode 100644 index 0000000..7759a2d --- /dev/null +++ b/src/features/community/lib/validation.ts @@ -0,0 +1,30 @@ +export interface BoardEditorFormState { + boardTitle: string; + boardContent: string; + imageFileUrls: string[]; +} + +export interface BoardEditorFormErrors { + boardTitle?: string; + boardContent?: string; +} + +export const INITIAL_BOARD_EDITOR_FORM_STATE: BoardEditorFormState = { + boardTitle: '', + boardContent: '', + imageFileUrls: [], +}; + +export function validateBoardEditorForm(form: BoardEditorFormState): BoardEditorFormErrors { + const errors: BoardEditorFormErrors = {}; + + if (!form.boardTitle.trim()) { + errors.boardTitle = '게시글 제목을 입력해주세요.'; + } + + if (!form.boardContent.trim()) { + errors.boardContent = '게시글 내용을 입력해주세요.'; + } + + return errors; +} diff --git a/src/features/community/model/useBoardDetail.ts b/src/features/community/model/useBoardDetail.ts new file mode 100644 index 0000000..7484dba --- /dev/null +++ b/src/features/community/model/useBoardDetail.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getBoardDetail } from '@/features/community/api/boards'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +export function useBoardDetail(boardId: number | null) { + const isValidId = boardId !== null && !Number.isNaN(boardId); + + return useQuery({ + queryKey: isValidId ? queryKeys.boards.detail(boardId) : ['boards', 'detail', 'idle'], + queryFn: () => getBoardDetail(boardId as number), + enabled: isValidId, + }); +} diff --git a/src/features/community/model/useBoardEditorForm.ts b/src/features/community/model/useBoardEditorForm.ts new file mode 100644 index 0000000..6da84f0 --- /dev/null +++ b/src/features/community/model/useBoardEditorForm.ts @@ -0,0 +1,324 @@ +import { useEffect, useMemo, useRef, useState, type ChangeEventHandler, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { uploadImages } from '@/shared/api/files'; +import { getApiErrorMessage } from '@/shared/lib/api/errorMessage'; +import { validateImageFiles } from '@/shared/lib/files/imageUploadPolicy'; + +import { + BOARD_DETAIL_STATUS_MESSAGES, + BOARD_DRAFT_STATUS_MESSAGES, + BOARD_MUTATION_STATUS_MESSAGES, +} from '../lib/constants'; +import { + clearStoredBoardDraftSessionKey, + getStoredBoardDraftSessionKey, + setStoredBoardDraftSessionKey, +} from '../lib/storage'; +import { + INITIAL_BOARD_EDITOR_FORM_STATE, + validateBoardEditorForm, + type BoardEditorFormErrors, + type BoardEditorFormState, +} from '../lib/validation'; +import type { BoardDetailResponse, TempSavedBoardResponse } from './types'; +import { useBoardDetail } from './useBoardDetail'; +import { useCreateBoard } from './useCreateBoard'; +import { useTempSaveBoard } from './useTempSaveBoard'; +import { useTempSavedBoard } from './useTempSavedBoard'; +import { useUpdateBoard } from './useUpdateBoard'; + +export type BoardEditorMode = 'create' | 'edit'; + +export interface UseBoardEditorFormOptions { + mode: BoardEditorMode; + boardId?: number | null; +} + +function mapBoardDetailToFormState(board: BoardDetailResponse): BoardEditorFormState { + return { + boardTitle: board.boardTitle, + boardContent: board.boardContent, + imageFileUrls: board.imageFileUrls ?? [], + }; +} + +function mapTempSavedBoardToFormState(board: TempSavedBoardResponse): BoardEditorFormState { + return { + boardTitle: board.boardTitle ?? '', + boardContent: board.boardContent ?? '', + imageFileUrls: board.imageFileUrl ? [board.imageFileUrl] : [], + }; +} + +export function useBoardEditorForm({ mode, boardId = null }: UseBoardEditorFormOptions) { + const navigate = useNavigate(); + const createBoardMutation = useCreateBoard(); + const updateBoardMutation = useUpdateBoard(); + const tempSaveBoardMutation = useTempSaveBoard(); + const [storedDraftSessionKey, setStoredDraftSessionKeyState] = useState(() => + mode === 'create' ? getStoredBoardDraftSessionKey() : null, + ); + const { + data: board, + isLoading: isBoardLoading, + isError: isBoardError, + error: boardError, + refetch: refetchBoard, + } = useBoardDetail(mode === 'edit' ? boardId : null); + const { + data: restoredDraft, + isLoading: isRestoringDraft, + isError: isDraftError, + error: draftError, + refetch: refetchDraft, + } = useTempSavedBoard(mode === 'create' ? storedDraftSessionKey : null); + + const [form, setForm] = useState(INITIAL_BOARD_EDITOR_FORM_STATE); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(''); + const [tempSaveError, setTempSaveError] = useState(''); + const [restoreError, setRestoreError] = useState(''); + const [imageError, setImageError] = useState(''); + const [uploadingImages, setUploadingImages] = useState(false); + const didHydrateBoardRef = useRef(false); + const didHydrateDraftRef = useRef(false); + + useEffect(() => { + if (mode !== 'edit' || !board || didHydrateBoardRef.current) { + return; + } + + setForm(mapBoardDetailToFormState(board)); + setErrors({}); + setSubmitError(''); + setTempSaveError(''); + setRestoreError(''); + didHydrateBoardRef.current = true; + }, [board, mode]); + + useEffect(() => { + if (mode !== 'create') { + return; + } + + if (!storedDraftSessionKey) { + didHydrateDraftRef.current = true; + setRestoreError(''); + return; + } + + if (!restoredDraft || didHydrateDraftRef.current) { + return; + } + + setForm(mapTempSavedBoardToFormState(restoredDraft)); + setErrors({}); + setSubmitError(''); + setTempSaveError(''); + setRestoreError(''); + didHydrateDraftRef.current = true; + }, [mode, restoredDraft, storedDraftSessionKey]); + + useEffect(() => { + if (!isDraftError) { + return; + } + + setRestoreError( + getApiErrorMessage( + draftError, + '임시 저장 게시글을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.', + BOARD_DRAFT_STATUS_MESSAGES, + ), + ); + }, [draftError, isDraftError]); + + const isPending = createBoardMutation.isPending || updateBoardMutation.isPending; + const isEditMode = mode === 'edit'; + + const handleFieldChange = + (field: keyof BoardEditorFormState): ChangeEventHandler => + (event) => { + const { value } = event.target; + + setForm((prev) => ({ + ...prev, + [field]: value, + })); + + setErrors((prev) => ({ + ...prev, + [field]: undefined, + })); + + setSubmitError(''); + setTempSaveError(''); + }; + + const handleSelectImages = async (files: FileList | File[]) => { + if (uploadingImages) { + return; + } + + const list = Array.from(files); + if (!list.length) { + return; + } + + try { + validateImageFiles(list); + } catch (error) { + setImageError(error instanceof Error ? error.message : '이미지 파일을 다시 확인해주세요.'); + return; + } + + setUploadingImages(true); + setImageError(''); + + try { + const uploadedUrls = await uploadImages(list); + setForm((prev) => ({ + ...prev, + imageFileUrls: [...prev.imageFileUrls, ...uploadedUrls], + })); + } catch (error) { + setImageError(getApiErrorMessage(error, '이미지 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.')); + } finally { + setUploadingImages(false); + } + }; + + const handleRemoveImage = (index: number) => { + setForm((prev) => ({ + ...prev, + imageFileUrls: prev.imageFileUrls.filter((_, imageIndex) => imageIndex !== index), + })); + setImageError(''); + }; + + const handleTempSave = async () => { + setTempSaveError(''); + + try { + const result = await tempSaveBoardMutation.mutateAsync({ + boardTitle: form.boardTitle.trim() || undefined, + boardContent: form.boardContent.trim() || undefined, + imageFileUrl: form.imageFileUrls[0] ?? undefined, + }); + + setStoredBoardDraftSessionKey(result.sessionKey); + setStoredDraftSessionKeyState(result.sessionKey); + didHydrateDraftRef.current = true; + } catch (error) { + setTempSaveError( + getApiErrorMessage( + error, + '게시글 임시 저장에 실패했습니다. 잠시 후 다시 시도해주세요.', + BOARD_DRAFT_STATUS_MESSAGES, + ), + ); + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const nextErrors = validateBoardEditorForm(form); + setErrors(nextErrors); + setSubmitError(''); + + if (Object.keys(nextErrors).length > 0 || uploadingImages || isPending) { + return; + } + + const payload = { + boardTitle: form.boardTitle.trim(), + boardContent: form.boardContent.trim(), + imageFileUrls: form.imageFileUrls, + }; + + try { + if (isEditMode) { + if (boardId === null) { + return; + } + + await updateBoardMutation.mutateAsync({ + boardId, + payload, + }); + + clearStoredBoardDraftSessionKey(); + void navigate(`/community/${boardId}`); + return; + } + + const result = await createBoardMutation.mutateAsync(payload); + clearStoredBoardDraftSessionKey(); + void navigate(`/community/${result.boardId}`); + } catch (error) { + setSubmitError( + getApiErrorMessage( + error, + isEditMode + ? '게시글 수정에 실패했습니다. 잠시 후 다시 시도해주세요.' + : '게시글 작성에 실패했습니다. 잠시 후 다시 시도해주세요.', + BOARD_MUTATION_STATUS_MESSAGES, + ), + ); + } + }; + + const resetStoredDraft = () => { + clearStoredBoardDraftSessionKey(); + setStoredDraftSessionKeyState(null); + setRestoreError(''); + didHydrateDraftRef.current = true; + }; + + const retryInitialLoad = async () => { + if (isEditMode) { + await refetchBoard(); + return; + } + + await refetchDraft(); + }; + + const initialLoadErrorMessage = useMemo(() => { + if (isEditMode && isBoardError) { + return getApiErrorMessage( + boardError, + '게시글 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.', + BOARD_DETAIL_STATUS_MESSAGES, + ); + } + + return restoreError; + }, [boardError, isBoardError, isEditMode, restoreError]); + + return { + form, + errors, + submitError, + tempSaveError, + restoreError, + imageError, + uploadingImages, + isPending, + isTempSaving: tempSaveBoardMutation.isPending, + isInitialLoading: isEditMode ? isBoardLoading : isRestoringDraft, + isInitialLoadError: isEditMode ? isBoardError : isDraftError, + initialLoadErrorMessage, + storedDraftSessionKey, + restoredDraft, + handleFieldChange, + handleSelectImages, + handleRemoveImage, + handleTempSave, + handleSubmit, + retryInitialLoad, + resetStoredDraft, + }; +} diff --git a/src/features/community/model/useCreateBoard.ts b/src/features/community/model/useCreateBoard.ts new file mode 100644 index 0000000..32d48ab --- /dev/null +++ b/src/features/community/model/useCreateBoard.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; + +import { createBoard } from '@/features/community/api/boards'; +import type { CreateBoardRequest, CreateBoardResponse } from '@/features/community/model/types'; + +export function useCreateBoard() { + return useMutation({ + mutationFn: createBoard, + }); +} diff --git a/src/features/community/model/useDeleteBoard.ts b/src/features/community/model/useDeleteBoard.ts new file mode 100644 index 0000000..d46f0b9 --- /dev/null +++ b/src/features/community/model/useDeleteBoard.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { deleteBoard } from '@/features/community/api/boards'; +import type { DeleteBoardResponse } from '@/features/community/model/types'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +interface DeleteBoardVariables { + boardId: number; +} + +export function useDeleteBoard() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ boardId }) => deleteBoard(boardId), + onSuccess: (_, variables) => { + void queryClient.removeQueries({ + queryKey: queryKeys.boards.detail(variables.boardId), + }); + }, + }); +} diff --git a/src/features/community/model/useTempSaveBoard.ts b/src/features/community/model/useTempSaveBoard.ts new file mode 100644 index 0000000..e87dd5c --- /dev/null +++ b/src/features/community/model/useTempSaveBoard.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; + +import { tempSaveBoard } from '@/features/community/api/boards'; +import type { TempSaveBoardRequest, TempSaveBoardResponse } from '@/features/community/model/types'; + +export function useTempSaveBoard() { + return useMutation({ + mutationFn: tempSaveBoard, + }); +} diff --git a/src/features/community/model/useTempSavedBoard.ts b/src/features/community/model/useTempSavedBoard.ts new file mode 100644 index 0000000..b69438d --- /dev/null +++ b/src/features/community/model/useTempSavedBoard.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getTempSavedBoard } from '@/features/community/api/boards'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +export function useTempSavedBoard(sessionKey: string | null) { + const hasSessionKey = Boolean(sessionKey?.trim()); + + return useQuery({ + queryKey: hasSessionKey ? queryKeys.boards.tempSaved(sessionKey as string) : ['boards', 'temp-save', 'idle'], + queryFn: () => getTempSavedBoard(sessionKey as string), + enabled: hasSessionKey, + }); +} diff --git a/src/features/community/model/useUpdateBoard.ts b/src/features/community/model/useUpdateBoard.ts new file mode 100644 index 0000000..3ed7f63 --- /dev/null +++ b/src/features/community/model/useUpdateBoard.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { updateBoard } from '@/features/community/api/boards'; +import type { UpdateBoardRequest, UpdateBoardResponse } from '@/features/community/model/types'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +interface UpdateBoardVariables { + boardId: number; + payload: UpdateBoardRequest; +} + +export function useUpdateBoard() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ boardId, payload }) => updateBoard(boardId, payload), + onSuccess: (_, variables) => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.boards.detail(variables.boardId), + }); + }, + }); +} From 10a4a0738d4f289c581fc18b5ec2474f0fdd5477 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 12 Jun 2026 19:35:56 +0900 Subject: [PATCH 03/13] =?UTF-8?q?:sparkles:=20Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=97=90?= =?UTF-8?q?=EB=94=94=ED=84=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작성/수정 플로우에서 공통으로 사용하는 게시글 에디터 폼 UI 추가 - 로딩 및 에러 상태를 포함한 커뮤니티 게시글 작성/수정 페이지 추가 - `/community/new` 및 `/communit`y/`/edit` 보호 라우트 연결 - 에디터 진입 CTA를 포함하도록 커뮤니티 랜딩 페이지 개선 - 기존 카드 기반 페이지 패턴에 맞춰 폼 스타일 유지 `#53` --- src/app/router.tsx | 18 ++ src/features/community/index.ts | 1 + src/features/community/ui/BoardEditorForm.tsx | 273 ++++++++++++++++++ src/pages/community/BoardCreatePage.tsx | 99 +++++++ src/pages/community/BoardEditPage.tsx | 124 ++++++++ src/pages/community/CommunityPage.tsx | 39 ++- 6 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 src/features/community/ui/BoardEditorForm.tsx create mode 100644 src/pages/community/BoardCreatePage.tsx create mode 100644 src/pages/community/BoardEditPage.tsx diff --git a/src/app/router.tsx b/src/app/router.tsx index f873d36..20e3e70 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,6 +6,8 @@ import { RequireAuth } from '@/app/guards/RequireAuth'; import { AuthCallbackPage } from '@/pages/auth/AuthCallbackPage'; import { LoginPage } from '@/pages/auth/LoginPage'; import { SignupPage } from '@/pages/auth/SignupPage'; +import { BoardCreatePage } from '@/pages/community/BoardCreatePage'; +import { BoardEditPage } from '@/pages/community/BoardEditPage'; import { FamilyJoinPage } from '@/pages/family/FamilyJoinPage'; import { CommunityPage } from '@/pages/community/CommunityPage'; import { MainPage } from '@/pages/main/MainPage'; @@ -26,6 +28,22 @@ export const router = createBrowserRouter([ { path: '/', element: }, { path: '/walk', element: }, { path: '/community', element: }, + { + path: '/community/new', + element: ( + + + + ), + }, + { + path: '/community/:boardId/edit', + element: ( + + + + ), + }, { path: '/my', element: ( diff --git a/src/features/community/index.ts b/src/features/community/index.ts index d8d5d12..d761525 100644 --- a/src/features/community/index.ts +++ b/src/features/community/index.ts @@ -18,6 +18,7 @@ export { useDeleteBoard } from './model/useDeleteBoard'; export { useTempSaveBoard } from './model/useTempSaveBoard'; export { useTempSavedBoard } from './model/useTempSavedBoard'; export { useUpdateBoard } from './model/useUpdateBoard'; +export { BoardEditorForm } from './ui/BoardEditorForm'; export type { BoardDetailResponse, BoardPayload, diff --git a/src/features/community/ui/BoardEditorForm.tsx b/src/features/community/ui/BoardEditorForm.tsx new file mode 100644 index 0000000..250a324 --- /dev/null +++ b/src/features/community/ui/BoardEditorForm.tsx @@ -0,0 +1,273 @@ +import type { ChangeEventHandler, FormEventHandler } from 'react'; +import { Link } from 'react-router-dom'; + +import { IMAGE_UPLOAD_POLICY_DESCRIPTION } from '@/shared/lib/files/imageUploadPolicy'; + +import type { BoardEditorFormErrors, BoardEditorFormState } from '../lib/validation'; + +interface BoardEditorFormProps { + form: BoardEditorFormState; + errors: BoardEditorFormErrors; + submitError: string; + tempSaveError: string; + imageError: string; + uploadingImages: boolean; + isPending: boolean; + isTempSaving: boolean; + storedDraftSessionKey: string | null; + onFieldChange: (field: keyof BoardEditorFormState) => ChangeEventHandler; + onSelectImages: (files: FileList | File[]) => void; + onRemoveImage: (index: number) => void; + onTempSave: () => void; + onSubmit: FormEventHandler; + mode?: 'create' | 'edit'; + cancelTo?: string; +} + +export function BoardEditorForm({ + form, + errors, + submitError, + tempSaveError, + imageError, + uploadingImages, + isPending, + isTempSaving, + storedDraftSessionKey, + onFieldChange, + onSelectImages, + onRemoveImage, + onTempSave, + onSubmit, + mode = 'create', + cancelTo = '/community', +}: BoardEditorFormProps) { + const isEditMode = mode === 'edit'; + const eyebrow = isEditMode ? 'BOARD EDIT' : 'BOARD WRITE'; + const heading = isEditMode ? '게시글 수정' : '게시글 작성'; + const description = isEditMode + ? '제목과 내용을 다듬고, 필요한 경우 이미지를 다시 구성해 게시글을 업데이트하세요.' + : '반려생활 이야기를 자유롭게 기록해보세요. 임시 저장 후 나중에 이어서 작성할 수도 있어요.'; + const submitLabel = isEditMode ? '수정 완료' : '게시하기'; + const pendingLabel = isEditMode ? '수정 중...' : '게시 중...'; + const helperMessage = storedDraftSessionKey + ? '임시 저장된 작성본이 이 브라우저에 연결되어 있어요.' + : '작성 중인 내용은 임시 저장 후 다시 이어서 편집할 수 있어요.'; + + return ( +
+
+

{eyebrow}

+

{heading}

+

{description}

+
+ +
+
+
+
+

작성 상태

+

{helperMessage}

+
+ + {storedDraftSessionKey ? '임시 저장본 연결됨' : '새 게시글'} + +
+
+
+
+ + +
+ +
+

임시 저장

+

+ 지금 상태를 저장하고 나중에 다시 이어서 쓸 수 있어요. +

+ + + + {tempSaveError ?

{tempSaveError}

: null} + {!tempSaveError && storedDraftSessionKey ? ( +

임시 저장된 게시글이 준비되어 있어요.

+ ) : null} +
+
+
+ +
+
+

이미지 첨부

+

{IMAGE_UPLOAD_POLICY_DESCRIPTION}

+
+ +
+ + + {imageError ?

{imageError}

: null} + + {form.imageFileUrls.length ? ( +
+ {form.imageFileUrls.map((imageUrl, index) => ( +
+ {`첨부 +
+ 이미지 {index + 1} + +
+
+ ))} +
+ ) : ( +
+ 아직 첨부된 이미지가 없어요. +
+ )} +
+
+ +
+

+ * 필수 입력 항목입니다. +

+ {submitError ?

{submitError}

: null} +
+ +
+ + 취소 + + +
+
+ ); +} + +function Field({ + label, + placeholder, + value, + onChange, + required = false, + error, +}: { + label: string; + placeholder?: string; + value: string; + onChange: ChangeEventHandler; + required?: boolean; + error?: string; +}) { + return ( + + ); +} + +function TextAreaField({ + label, + placeholder, + value, + onChange, + required = false, + error, +}: { + label: string; + placeholder?: string; + value: string; + onChange: ChangeEventHandler; + required?: boolean; + error?: string; +}) { + return ( +