diff --git a/src/app/router.tsx b/src/app/router.tsx index f873d36..db4919c 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -6,8 +6,12 @@ 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 { FamilyJoinPage } from '@/pages/family/FamilyJoinPage'; +import { BoardCreatePage } from '@/pages/community/BoardCreatePage'; +import { BoardDetailPage } from '@/pages/community/BoardDetailPage'; +import { BoardEditPage } from '@/pages/community/BoardEditPage'; +import { CommunityMyActivityPage } from '@/pages/community/CommunityMyActivityPage'; import { CommunityPage } from '@/pages/community/CommunityPage'; +import { FamilyJoinPage } from '@/pages/family/FamilyJoinPage'; import { MainPage } from '@/pages/main/MainPage'; import { PetDetailPage } from '@/pages/my/PetDetailPage'; import { PetEditPage } from '@/pages/my/PetEditPage'; @@ -26,6 +30,38 @@ export const router = createBrowserRouter([ { path: '/', element: }, { path: '/walk', element: }, { path: '/community', element: }, + { + path: '/community/my', + element: ( + + + + ), + }, + { + path: '/community/new', + element: ( + + + + ), + }, + { + path: '/community/:boardId', + element: ( + + + + ), + }, + { + path: '/community/:boardId/edit', + element: ( + + + + ), + }, { path: '/my', element: ( diff --git a/src/features/community/api/boards.ts b/src/features/community/api/boards.ts new file mode 100644 index 0000000..2333f3b --- /dev/null +++ b/src/features/community/api/boards.ts @@ -0,0 +1,52 @@ +import { apiClient } from '@/shared/api/axios'; + +import type { + BoardDetailResponse, + BoardListResponse, + CreateBoardRequest, + CreateBoardResponse, + DeleteBoardResponse, + TempSavedBoardResponse, + TempSaveBoardRequest, + TempSaveBoardResponse, + UpdateBoardRequest, + UpdateBoardResponse, +} from '../model/types'; + +export async function getBoardList(params: { page: number; size: number }): Promise { + const response = await apiClient.get('/boards', { params }); + return response.data; +} + +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 { boardId, ...body } = payload; + const response = await apiClient.post('/boards/temp-save', body, { + params: boardId !== undefined ? { boardId } : undefined, + }); + 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..56ac648 --- /dev/null +++ b/src/features/community/index.ts @@ -0,0 +1,53 @@ +export { + createBoard, + deleteBoard, + getBoardDetail, + getBoardList, + getTempSavedBoard, + tempSaveBoard, + updateBoard, +} from './api/boards'; +export { + BOARD_DETAIL_STATUS_MESSAGES, + BOARD_DRAFT_STATUS_MESSAGES, + BOARD_LIST_STATUS_MESSAGES, + 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 { useBoardList } from './model/useBoardList'; +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 { CommunityFeedCard } from './ui/CommunityFeedCard'; +export { CommunityLayout } from './ui/CommunityLayout'; +export { CommunityProfileCard } from './ui/CommunityProfileCard'; +export { CommunitySidebarPanel } from './ui/CommunitySidebarPanel'; +export { BoardDetailContent } from './ui/BoardDetailContent'; +export { BoardEditorForm } from './ui/BoardEditorForm'; +export { DeleteBoardDialog } from './ui/DeleteBoardDialog'; +export type { + BoardDetailResponse, + BoardListItem, + BoardListResponse, + BoardPayload, + CreateBoardRequest, + CreateBoardResponse, + DeleteBoardResponse, + TempSavedBoardResponse, + TempSaveBoardRequest, + TempSaveBoardResponse, + 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/constants.ts b/src/features/community/lib/constants.ts new file mode 100644 index 0000000..b9116dd --- /dev/null +++ b/src/features/community/lib/constants.ts @@ -0,0 +1,30 @@ +export const COMMUNITY_DRAFT_SESSION_KEY = 'community-board-draft-session-key'; + +export const BOARD_LIST_STATUS_MESSAGES: Partial> = { + 400: '잘못된 요청입니다.', + 401: '로그인이 필요합니다. 다시 로그인해주세요.', + 500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', +}; + +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/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/types.ts b/src/features/community/model/types.ts new file mode 100644 index 0000000..9eeb58b --- /dev/null +++ b/src/features/community/model/types.ts @@ -0,0 +1,77 @@ +export interface BoardListItem { + boardId: number; + boardTitle: string; + boardContentPreview: string; + thumbnailImageUrl: string | null; + nickname: string; + viewCount: number; + commentCount: number; + likeCount: number; + dislikeCount: number; + createdAt: string; + modifiedAt: string; +} + +export interface BoardListResponse { + message: string; + boards: BoardListItem[]; +} + +export interface BoardPayload { + boardTitle: string; + boardContent: string; + imageFileUrls: string[]; +} + +export type CreateBoardRequest = BoardPayload; + +export interface CreateBoardResponse { + message: string; + boardId: number; +} + +export interface TempSaveBoardRequest { + boardId?: number; + boardTitle?: string; + boardContent?: string; + imageFileUrl?: string; + imageFileUrls?: string[]; +} + +export interface TempSaveBoardResponse { + message: string; + sessionKey: string; +} + +export interface TempSavedBoardResponse { + message: string; + boardTitle: string; + boardContent: string; + imageFileUrl: string | null; + imageFileUrls?: string[] | null; +} + +export interface BoardDetailResponse { + message: string; + boardId: number; + boardTitle: string; + boardContent: string; + imageFileUrls: string[]; + profileUrl: string | null; + nickname: string; + likeCount?: number; + commentCount?: number; + viewCount: number; + boardCreatedAt: string; + modifiedAt: string; +} + +export type UpdateBoardRequest = BoardPayload; + +export interface UpdateBoardResponse { + message: string; +} + +export interface DeleteBoardResponse { + message: string; +} 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..75a5b7c --- /dev/null +++ b/src/features/community/model/useBoardEditorForm.ts @@ -0,0 +1,330 @@ +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 { + const imageFileUrls = + board.imageFileUrls?.filter((imageUrl): imageUrl is string => Boolean(imageUrl?.trim())) ?? + (board.imageFileUrl ? [board.imageFileUrl] : []); + + return { + boardTitle: board.boardTitle ?? '', + boardContent: board.boardContent ?? '', + imageFileUrls, + }; +} + +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({ + boardId: isEditMode && boardId !== null ? boardId : undefined, + boardTitle: form.boardTitle.trim() || undefined, + boardContent: form.boardContent.trim() || undefined, + imageFileUrl: form.imageFileUrls[0] ?? undefined, + imageFileUrls: form.imageFileUrls.length ? form.imageFileUrls : 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/useBoardList.ts b/src/features/community/model/useBoardList.ts new file mode 100644 index 0000000..44b17ff --- /dev/null +++ b/src/features/community/model/useBoardList.ts @@ -0,0 +1,18 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { getBoardList } from '../api/boards'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +const PAGE_SIZE = 12; + +export function useBoardList() { + return useInfiniteQuery({ + queryKey: queryKeys.boards.listInfinite(), + queryFn: ({ pageParam }) => getBoardList({ page: pageParam, size: PAGE_SIZE }), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.boards.length < PAGE_SIZE) return undefined; + return allPages.length; + }, + }); +} 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..163f793 --- /dev/null +++ b/src/features/community/model/useDeleteBoard.ts @@ -0,0 +1,25 @@ +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), + }); + void queryClient.invalidateQueries({ + queryKey: ['boards'], + }); + }, + }); +} 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..a1aa727 --- /dev/null +++ b/src/features/community/model/useUpdateBoard.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { updateBoard } from '@/features/community/api/boards'; +import type { BoardDetailResponse, 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) => { + queryClient.setQueryData( + queryKeys.boards.detail(variables.boardId), + (previous) => { + if (!previous) { + return previous; + } + + return { + ...previous, + boardTitle: variables.payload.boardTitle, + boardContent: variables.payload.boardContent, + imageFileUrls: variables.payload.imageFileUrls, + modifiedAt: new Date().toISOString(), + }; + }, + ); + + void queryClient.invalidateQueries({ + queryKey: queryKeys.boards.listInfinite(), + }); + }, + }); +} diff --git a/src/features/community/ui/BoardDetailContent.tsx b/src/features/community/ui/BoardDetailContent.tsx new file mode 100644 index 0000000..48a640d --- /dev/null +++ b/src/features/community/ui/BoardDetailContent.tsx @@ -0,0 +1,281 @@ +import { Link } from 'react-router-dom'; + +import type { BoardDetailResponse } from '../model/types'; + +interface BoardDetailContentProps { + board: BoardDetailResponse; + canManage: boolean; + currentUserProfileUrl?: string | null; + onDelete: () => void; +} + +const DETAIL_COPY = { + authorFallback: '작성자', + report: '신고', + edit: '수정', + delete: '삭제', + commentsTitle: '댓글', + placeholder: '댓글을 입력해주세요', + submit: '등록', + submitAria: '댓글 등록 예정 버튼', + viewLabel: '조회', +}; + +const MOCK_COMMENTS = [ + { + id: 1, + nickname: '배웅배웅', + dateTime: '2025-10-14 11:25:00', + content: '어머~^^ 좋은 정보 감사드려요! 행복하세요 :D', + }, + { + id: 2, + nickname: '배웅배웅', + dateTime: '2025-10-14 11:25:00', + content: '어머~^^ 좋은 정보 감사드려요! 행복하세요 :D', + }, +]; + +function formatDateTime(value: string) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +export function BoardDetailContent({ board, canManage, currentUserProfileUrl, onDelete }: BoardDetailContentProps) { + const likeCount = board.likeCount ?? 0; + const commentCount = board.commentCount ?? MOCK_COMMENTS.length; + const authorName = board.nickname.trim() || DETAIL_COPY.authorFallback; + const imageUrls = board.imageFileUrls.filter((imageUrl) => imageUrl.trim().length > 0); + + return ( +
+
+
+
+
+ +
+

{authorName}

+
+ {formatDateTime(board.boardCreatedAt)} + + + + + {DETAIL_COPY.viewLabel} {board.viewCount} + + +
+
+
+ +
+ {!canManage ? ( + + ) : null} + {canManage ? ( + <> + + {DETAIL_COPY.edit} + + + + ) : null} +
+
+ +
+

+ {board.boardTitle} +

+ +
+ {imageUrls.length > 0 ? : null} + +

+ {board.boardContent} +

+ +
+ + +
+
+
+
+
+ +
+
+

+ {DETAIL_COPY.commentsTitle} {commentCount} +

+
+ +
+ {MOCK_COMMENTS.map((comment) => ( + + ))} +
+
+ +
+
+
+ +
+ {DETAIL_COPY.placeholder} +
+ +
+
+
+
+ ); +} + +function BoardImageGallery({ title, imageUrls }: { title: string; imageUrls: string[] }) { + if (imageUrls.length === 1) { + return ( +
+ {title} +
+ ); + } + + return ( +
+ {imageUrls.map((imageUrl, index) => ( +
+ {`${title} +
+ ))} +
+ ); +} + +function AuthorBadge({ name, size, profileUrl }: { name: string; size: 'sm' | 'lg'; profileUrl?: string | null }) { + const wrapperSizeClass = size === 'lg' ? 'h-14 w-14' : 'h-10 w-10'; + const textSizeClass = size === 'lg' ? 'text-lg' : 'text-sm'; + const normalizedProfileUrl = profileUrl?.trim(); + + return ( +
+ {normalizedProfileUrl ? ( + {name} + ) : ( +
+ {name.slice(0, 1)} +
+ )} +
+ ); +} + +function SocialStat({ kind, value }: { kind: 'like' | 'comment'; value: number }) { + return ( + + {kind === 'like' ? ( + + ) : ( + + )} + {value} + + ); +} + +function CommentRow({ nickname, dateTime, content }: { nickname: string; dateTime: string; content: string }) { + return ( +
+
+
+ +
+

{nickname}

+

{dateTime}

+
+
+
+ + + +
+
+

{content}

+
+ ); +} + +function MetaDivider() { + return |; +} + +function ThumbsUpIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function CommentIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function EyeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/src/features/community/ui/BoardEditorForm.tsx b/src/features/community/ui/BoardEditorForm.tsx new file mode 100644 index 0000000..8d85136 --- /dev/null +++ b/src/features/community/ui/BoardEditorForm.tsx @@ -0,0 +1,250 @@ +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 ? '수정 중...' : '게시 중...'; + + return ( +
+
+

{eyebrow}

+

{heading}

+ {description ?

{description}

: null} + {storedDraftSessionKey ? ( +

{'임시 저장된 내용을 이어서 작성 중이에요.'}

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

{'이미지 첨부'}

+

{IMAGE_UPLOAD_POLICY_DESCRIPTION}

+
+ +
+ + + {imageError ?

{imageError}

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

+ * {'필수 입력 항목입니다.'} +

+ {submitError ?

{submitError}

: null} +
+ + {tempSaveError ?

{tempSaveError}

: 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 ( +