diff --git a/index.html b/index.html index 3789999e..89de05ce 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ overscroll-behavior: none; touch-action: pan-y pinch-zoom; } - + * { overscroll-behavior-x: none; } @@ -15,50 +15,78 @@ // 좌우 스와이프 네비게이션 완전 차단 let startX = null; let startY = null; - - document.addEventListener('touchstart', function(e) { - startX = e.touches[0].clientX; - startY = e.touches[0].clientY; - }, { passive: false }); - - document.addEventListener('touchmove', function(e) { - if (startX === null || startY === null) return; - - let currentX = e.touches[0].clientX; - let currentY = e.touches[0].clientY; - let diffX = Math.abs(currentX - startX); - let diffY = Math.abs(currentY - startY); - - // 수평 스와이프 감지시 무조건 차단 - if (diffX > 5 && diffX > diffY) { - e.preventDefault(); - e.stopPropagation(); - return false; - } - }, { passive: false, capture: true }); - - document.addEventListener('touchend', function(e) { - startX = null; - startY = null; - }, { passive: false }); - - document.addEventListener('touchcancel', function(e) { - startX = null; - startY = null; - }, { passive: false }); - + + document.addEventListener( + 'touchstart', + function (e) { + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + }, + { passive: false }, + ); + + document.addEventListener( + 'touchmove', + function (e) { + if (startX === null || startY === null) return; + + let currentX = e.touches[0].clientX; + let currentY = e.touches[0].clientY; + let diffX = Math.abs(currentX - startX); + let diffY = Math.abs(currentY - startY); + + // 수평 스와이프 감지시 무조건 차단 + if (diffX > 5 && diffX > diffY) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }, + { passive: false, capture: true }, + ); + + document.addEventListener( + 'touchend', + function (e) { + startX = null; + startY = null; + }, + { passive: false }, + ); + + document.addEventListener( + 'touchcancel', + function (e) { + startX = null; + startY = null; + }, + { passive: false }, + ); + // 추가적인 제스처 차단 - document.addEventListener('gesturestart', function(e) { - e.preventDefault(); - }, { passive: false }); - - document.addEventListener('gesturechange', function(e) { - e.preventDefault(); - }, { passive: false }); - - document.addEventListener('gestureend', function(e) { - e.preventDefault(); - }, { passive: false }); + document.addEventListener( + 'gesturestart', + function (e) { + e.preventDefault(); + }, + { passive: false }, + ); + + document.addEventListener( + 'gesturechange', + function (e) { + e.preventDefault(); + }, + { passive: false }, + ); + + document.addEventListener( + 'gestureend', + function (e) { + e.preventDefault(); + }, + { passive: false }, + ); + @@ -94,6 +131,7 @@ +
diff --git a/package.json b/package.json index 206784b0..9aaac5c5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "preview": "vite preview", "format": "prettier . --write", - "format:check": "prettier . --check" + "format:check": "prettier . --check", + "dev:lan": "vite --host 0.0.0.0 --port 5173" }, "dependencies": { "@emotion/css": "^11.13.5", diff --git a/src/api/feeds/createFeed.ts b/src/api/feeds/createFeed.ts index a153147b..eef2b51d 100644 --- a/src/api/feeds/createFeed.ts +++ b/src/api/feeds/createFeed.ts @@ -6,6 +6,7 @@ export interface CreateFeedBody { contentBody: string; isPublic: boolean; tagList?: string[]; + imageUrls?: string[]; } /** 성공 응답 */ @@ -29,27 +30,12 @@ export type CreateFeedResponse = CreateFeedSuccess | CreateFeedFail; /** * 피드 작성 API - * - multipart/form-data - * - request: application/json (Blob로 감싸 전송) - * - images: File[] (선택값, 없으면 미첨부) + * - application/json (presigned URL 방식) + * - imageUrls: 미리 S3에 업로드한 이미지의 CloudFront URL 목록 */ export const createFeed = async ( body: CreateFeedBody, - images?: File[], ): Promise => { - const form = new FormData(); - - // request 파트(JSON) - 필수 - form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' })); - - // images 파트들 - 선택 - if (images && images.length > 0) { - images.forEach(file => form.append('images', file)); - } - - const { data } = await apiClient.post('/feeds', form, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - + const { data } = await apiClient.post('/feeds', body); return data; }; diff --git a/src/api/feeds/getPresignedUrl.ts b/src/api/feeds/getPresignedUrl.ts new file mode 100644 index 00000000..4162452a --- /dev/null +++ b/src/api/feeds/getPresignedUrl.ts @@ -0,0 +1,29 @@ +import { apiClient } from '../index'; + +export interface PresignedUrlRequest { + extension: string; + size: number; +} + +export interface PresignedUrlResponse { + isSuccess: boolean; + code: number; + message: string; + data?: { + presignedUrls: Array<{ + presignedUrl: string; + fileUrl: string; + }>; + }; +} + +export const getPresignedUrl = async ( + requests: PresignedUrlRequest[] +): Promise => { + const { data } = await apiClient.post( + '/feeds/images/presigned-url', + requests + ); + + return data; +}; \ No newline at end of file diff --git a/src/api/feeds/uploadToS3.ts b/src/api/feeds/uploadToS3.ts new file mode 100644 index 00000000..72cda85a --- /dev/null +++ b/src/api/feeds/uploadToS3.ts @@ -0,0 +1,19 @@ +export const uploadFileToS3 = async ( + presignedUrl: string, + file: File +): Promise => { + try { + const response = await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, + }); + + return response.ok; + } catch (error) { + console.error('S3 업로드 실패:', error); + return false; + } +}; \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index 10a237c1..99a41c10 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -10,7 +10,6 @@ export const apiClient = axios.create({ headers: { 'Content-Type': 'application/json', }, - withCredentials: true, // 쿠키 자동 전송 설정 }); // Request 인터셉터: localStorage의 토큰을 헤더에 자동 추가 apiClient.interceptors.request.use( @@ -19,13 +18,9 @@ apiClient.interceptors.request.use( const authToken = localStorage.getItem('authToken'); if (authToken) { - // 토큰이 있으면 Authorization 헤더에 추가 - console.log('🔑 Authorization 헤더에 토큰 추가'); config.headers.Authorization = `Bearer ${authToken}`; } else { console.log('❌ localStorage에 토큰이 없습니다.'); - // config.headers.Authorization = - // 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.BSGuoMWlrzc0oKgSJXHEycxdzzY9-e7gD4xh-wSDemc'; } return config; diff --git a/src/api/record/updateRecord.ts b/src/api/record/updateRecord.ts new file mode 100644 index 00000000..8c15b7c6 --- /dev/null +++ b/src/api/record/updateRecord.ts @@ -0,0 +1,44 @@ +import { apiClient } from '../index'; +import type { UpdateRecordRequest, UpdateRecordData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type UpdateRecordResponse = ApiResponse; + +// 기록 수정 API 함수 +export const updateRecord = async ( + roomId: number, + recordId: number, + recordData: UpdateRecordRequest, +): Promise => { + try { + const response = await apiClient.patch( + `/rooms/${roomId}/records/${recordId}`, + recordData, + ); + return response.data; + } catch (error) { + console.error('기록 수정 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +const recordData: UpdateRecordRequest = { + content: "수정된 기록 내용입니다~~" +}; + +try { + const result = await updateRecord(1, 123, recordData); + if (result.isSuccess) { + console.log("수정된 방 ID:", result.data.roomId); + // 성공 처리 로직 + } else { + console.error("기록 수정 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ \ No newline at end of file diff --git a/src/api/record/updateVote.ts b/src/api/record/updateVote.ts new file mode 100644 index 00000000..d9589eec --- /dev/null +++ b/src/api/record/updateVote.ts @@ -0,0 +1,44 @@ +import { apiClient } from '../index'; +import type { UpdateVoteRequest, UpdateVoteData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type UpdateVoteResponse = ApiResponse; + +// 투표 수정 API 함수 +export const updateVote = async ( + roomId: number, + voteId: number, + voteData: UpdateVoteRequest, +): Promise => { + try { + const response = await apiClient.patch( + `/rooms/${roomId}/votes/${voteId}`, + voteData, + ); + return response.data; + } catch (error) { + console.error('투표 수정 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +const voteData: UpdateVoteRequest = { + content: "수정된 투표 내용입니다~" +}; + +try { + const result = await updateVote(1, 123, voteData); + if (result.isSuccess) { + console.log("수정된 방 ID:", result.data.roomId); + // 성공 처리 로직 + } else { + console.error("투표 수정 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ \ No newline at end of file diff --git a/src/api/rooms/deleteDailyGreeting.ts b/src/api/rooms/deleteDailyGreeting.ts new file mode 100644 index 00000000..2261b105 --- /dev/null +++ b/src/api/rooms/deleteDailyGreeting.ts @@ -0,0 +1,28 @@ +import { apiClient } from '../index'; + +// 오늘의 한마디 삭제 응답 타입 +export interface DeleteDailyGreetingResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomId: number; + }; +} + +// 오늘의 한마디 삭제 API 함수 +export const deleteDailyGreeting = async ( + roomId: number, + attendanceCheckId: number, +): Promise => { + try { + const response = await apiClient.delete( + `/rooms/${roomId}/daily-greeting/${attendanceCheckId}`, + ); + + return response.data; + } catch (error) { + console.error('오늘의 한마디 삭제 API 오류:', error); + throw error; + } +}; \ No newline at end of file diff --git a/src/api/rooms/leaveRoom.ts b/src/api/rooms/leaveRoom.ts new file mode 100644 index 00000000..11d2152f --- /dev/null +++ b/src/api/rooms/leaveRoom.ts @@ -0,0 +1,21 @@ +import { apiClient } from '../index'; + +// 방 나가기 응답 타입 +export interface LeaveRoomResponse { + isSuccess: boolean; + code: number; + message: string; + data: string; +} + +// 방 나가기 API 함수 +export const leaveRoom = async (roomId: number): Promise => { + try { + const response = await apiClient.delete(`/rooms/${roomId}/leave`); + + return response.data; + } catch (error) { + console.error('방 나가기 API 오류:', error); + throw error; + } +}; \ No newline at end of file diff --git a/src/api/users/getUsers.ts b/src/api/users/getUsers.ts index 27419034..598e1a66 100644 --- a/src/api/users/getUsers.ts +++ b/src/api/users/getUsers.ts @@ -3,7 +3,7 @@ import { apiClient } from '../index'; export interface UserData { userId: number; nickname: string; - profileImgUrl: string; + profileImageUrl: string; aliasName: string; aliasColor: string; followerCount: number; diff --git a/src/components/common/Modal/MoreMenu.tsx b/src/components/common/Modal/MoreMenu.tsx index a5da92e3..d678a935 100644 --- a/src/components/common/Modal/MoreMenu.tsx +++ b/src/components/common/Modal/MoreMenu.tsx @@ -51,15 +51,23 @@ const MoreMenu = ({ onEdit, onDelete, onClose, onReport, isWriter, type }: MoreM const Overlay = styled.div` position: fixed; - left: 0; - right: 0; - top: 0; - bottom: 0; + inset: 0; display: flex; justify-content: center; align-items: flex-end; - width: 100vw; + + /* ⬇︎ 핵심: 실제 보이는 높이로 맞추기 (dvh 폴백 포함) */ height: 100vh; + @supports (height: 100dvh) { + height: 100dvh; + } + /* JS 폴백 변수 */ + height: var(--vvh, 100dvh); + + /* ⬇︎ 하단 안전영역만큼 띄워서 탭바/홈바를 피함 */ + padding-bottom: env(safe-area-inset-bottom, 0); + + width: 100vw; background-color: rgba(18, 18, 18, 0.1); backdrop-filter: blur(2.5px); z-index: 1200; @@ -75,6 +83,7 @@ const ReportContainer = styled.div` height: 90px; padding: 20px; border-radius: 12px 12px 0px 0px; + padding-bottom: calc(20px + env(safe-area-inset-bottom, 0)); background-color: ${colors.darkgrey.main}; `; diff --git a/src/components/feed/FollowList.tsx b/src/components/feed/FollowList.tsx index ecbb8030..bb7d4106 100644 --- a/src/components/feed/FollowList.tsx +++ b/src/components/feed/FollowList.tsx @@ -164,7 +164,7 @@ const EmptyFollowerContainer = styled.div` margin: 12px 0; justify-content: space-between; align-items: center; - border-radius: 8px; + border-radius: 12px; background-color: var(--color-darkgrey-dark); color: var(--color-grey-100); diff --git a/src/components/feed/UserProfileItem.tsx b/src/components/feed/UserProfileItem.tsx index 2688ab6e..931c0b09 100644 --- a/src/components/feed/UserProfileItem.tsx +++ b/src/components/feed/UserProfileItem.tsx @@ -8,7 +8,7 @@ import { postFollow } from '@/api/users/postFollow'; import { usePopupStore } from '@/stores/usePopupStore'; const UserProfileItem = ({ - profileImgUrl, + profileImageUrl, nickname, aliasName, aliasColor, @@ -59,7 +59,7 @@ const UserProfileItem = ({
- +
{nickname}
diff --git a/src/components/members/MemberList.styled.ts b/src/components/members/MemberList.styled.ts index 429be39b..d354167e 100644 --- a/src/components/members/MemberList.styled.ts +++ b/src/components/members/MemberList.styled.ts @@ -13,13 +13,8 @@ export const MemberItem = styled.div` justify-content: space-between; padding: 20px; cursor: pointer; - transition: background-color 0.2s; position: relative; - &:hover { - background-color: ${colors.darkgrey['50']}; - } - &:focus-visible { outline: 2px solid ${semanticColors.text.point.green}; outline-offset: -2px; @@ -67,8 +62,30 @@ export const MemberName = styled.div` font-weight: ${typography.fontWeight.medium}; `; -export const MemberRole = styled.div` - color: ${semanticColors.text.point.green}; +export const MemberRole = styled.div<{ roleType?: string }>` + color: ${({ roleType }) => { + if (!roleType) return semanticColors.text.point.green; + + const role = roleType.toLowerCase(); + + if (role.includes('예술') || role.includes('art')) { + return semanticColors.text.character.pink; + } + if (role.includes('문학') || role.includes('literature')) { + return semanticColors.text.character.mint; + } + if (role.includes('사회') || role.includes('sociology')) { + return semanticColors.text.character.orange; + } + if (role.includes('인문') || role.includes('humanities')) { + return semanticColors.text.character.skyblue; + } + if (role.includes('과학') || role.includes('science')) { + return semanticColors.text.character.lavender; + } + + return semanticColors.text.point.green; + }}; font-size: ${typography.fontSize.xs}; font-weight: ${typography.fontWeight.regular}; `; diff --git a/src/components/members/MemberList.tsx b/src/components/members/MemberList.tsx index ffd7bb29..9a1e5cdd 100644 --- a/src/components/members/MemberList.tsx +++ b/src/components/members/MemberList.tsx @@ -59,7 +59,7 @@ const MemberList = ({ members, onMemberClick }: MemberListProps) => { )} {member.nickname} - {member.role} + {member.role} diff --git a/src/components/memory/RecordItem/PollRecord.tsx b/src/components/memory/RecordItem/PollRecord.tsx index d714ce09..5058606a 100644 --- a/src/components/memory/RecordItem/PollRecord.tsx +++ b/src/components/memory/RecordItem/PollRecord.tsx @@ -67,7 +67,8 @@ const PollRecord = ({ content, pollOptions, postId, shouldBlur = false, onVoteUp }, [pollOptions]); // 투표 옵션 클릭 핸들러 - const handleOptionClick = async (option: PollOption) => { + const handleOptionClick = async (e: React.MouseEvent, option: PollOption) => { + e.stopPropagation(); // 이벤트 버블링 방지 if (isVoting || !roomId || shouldBlur) return; setIsVoting(true); @@ -152,7 +153,7 @@ const PollRecord = ({ content, pollOptions, postId, shouldBlur = false, onVoteUp handleOptionClick(option)} + onClick={shouldBlur ? undefined : (e) => handleOptionClick(e, option)} style={{ cursor: shouldBlur ? 'default' : (isVoting ? 'not-allowed' : 'pointer'), opacity: shouldBlur ? 1 : (isVoting ? 0.7 : 1), diff --git a/src/components/memory/RecordItem/RecordItem.styled.ts b/src/components/memory/RecordItem/RecordItem.styled.ts index dd293a2e..10b52443 100644 --- a/src/components/memory/RecordItem/RecordItem.styled.ts +++ b/src/components/memory/RecordItem/RecordItem.styled.ts @@ -5,7 +5,7 @@ export const Container = styled.div<{ shouldBlur?: boolean }>` background-color: none; filter: ${({ shouldBlur }) => (shouldBlur ? 'blur(2px)' : 'none')}; transition: filter 0.3s ease; - pointer-events: ${({ shouldBlur }) => (shouldBlur ? 'none' : 'auto')}; + position: relative; `; export const UserSection = styled.div` @@ -19,10 +19,11 @@ export const UserAvatar = styled.div<{ src?: string }>` height: 36px; border-radius: 50%; background-color: ${colors.grey[400]}; - background-image: ${({ src }) => src ? `url(${src})` : 'none'}; + background-image: ${({ src }) => (src ? `url(${src})` : 'none')}; background-size: cover; background-position: center; margin-right: 8px; + border: 0.5px solid ${colors.grey[300]}; `; export const UserInfo = styled.div` diff --git a/src/components/memory/RecordItem/RecordItem.tsx b/src/components/memory/RecordItem/RecordItem.tsx index 8d8645a4..ea71c59f 100644 --- a/src/components/memory/RecordItem/RecordItem.tsx +++ b/src/components/memory/RecordItem/RecordItem.tsx @@ -125,9 +125,32 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { }; const handleEdit = useCallback(() => { - // API 개발 전까지 수정 기능 비활성화 - console.log('수정 기능은 개발 중입니다.'); - }, []); + if (!roomId) return; + + // 바텀시트 닫기 + closePopup(); + + if (type === 'poll') { + // 투표 수정 페이지로 이동 (투표 정보를 쿼리 파라미터로 전달) + const params = new URLSearchParams({ + content: content, + pageRange: pageRange || '', + recordType: recordType || 'normal', + options: JSON.stringify(pollOptions?.map(option => option.text) || []) + }); + + navigate(`/memory/poll/edit/${roomId}/${record.id}?${params.toString()}`); + } else { + // 기록 수정 페이지로 이동 (기록 정보를 쿼리 파라미터로 전달) + const params = new URLSearchParams({ + content: content, + pageRange: pageRange || '', + recordType: recordType || 'normal' + }); + + navigate(`/memory/record/edit/${roomId}/${record.id}?${params.toString()}`); + } + }, [roomId, record.id, content, pageRange, recordType, type, pollOptions, navigate, closePopup]); const handleDelete = useCallback(async () => { const currentRoomId = roomId || '1'; @@ -266,7 +289,13 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { }, [openCommentBottomSheet, id, type]); - const handleClick = useCallback(() => { + const handleClick = useCallback((e: React.MouseEvent) => { + // 블라인드된 상태에서는 클릭 이벤트 무시 + if (shouldBlur) { + e.stopPropagation(); + return; + } + // 클릭으로 더보기 메뉴 표시 if (isMyRecord) { const menuOptions: any = { @@ -303,11 +332,28 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { return ( + {shouldBlur && ( +
{ + e.stopPropagation(); + }} + /> + )} diff --git a/src/components/pollwrite/PollCreationSection.tsx b/src/components/pollwrite/PollCreationSection.tsx index 5bc0ce8f..92b5dca1 100644 --- a/src/components/pollwrite/PollCreationSection.tsx +++ b/src/components/pollwrite/PollCreationSection.tsx @@ -17,6 +17,8 @@ interface PollCreationSectionProps { onContentChange: (value: string) => void; options: string[]; onOptionsChange: (options: string[]) => void; + isEditMode?: boolean; // 수정 모드 여부 + autoFocus?: boolean; // 자동 포커스 및 커서 위치 설정 여부 } const PollCreationSection = ({ @@ -24,11 +26,14 @@ const PollCreationSection = ({ onContentChange, options, onOptionsChange, + isEditMode = false, + autoFocus = false, }: PollCreationSectionProps) => { const maxContentLength = 20; const maxOptions = 5; const [focusStates, setFocusStates] = useState(options.map(() => false)); const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const contentInputRef = useRef(null); const handleContentChange = (e: React.ChangeEvent) => { const value = e.target.value; @@ -87,10 +92,22 @@ const PollCreationSection = ({ } }, [options.length, focusStates.length]); + // autoFocus 처리 + useEffect(() => { + if (autoFocus && contentInputRef.current) { + const input = contentInputRef.current; + input.focus(); + // 커서를 텍스트 끝으로 이동 + const length = input.value.length; + input.setSelectionRange(length, length); + } + }, [autoFocus]); + return (
handleOptionFocus(index)} onBlur={() => handleOptionBlur(index)} maxLength={20} + disabled={isEditMode} + readOnly={isEditMode} /> - {/* 텍스트가 있을 때는 X 아이콘으로 텍스트 삭제 */} - {option.trim() !== '' && ( - handleClearOption(index)}> - 텍스트 삭제 - - )} - {/* 텍스트가 없고 3번째 항목(index >= 2)부터만 쓰레기통 아이콘으로 항목 삭제 */} - {option.trim() === '' && index >= 2 && ( - handleRemoveOption(index)}> - 항목 삭제 - + {/* 수정 모드가 아니어야만 삭제 버튼 표시 */} + {!isEditMode && ( + <> + {/* 텍스트가 있을 때는 X 아이콘으로 텍스트 삭제 */} + {option.trim() !== '' && ( + handleClearOption(index)}> + 텍스트 삭제 + + )} + {/* 텍스트가 없고 3번째 항목(index >= 2)부터만 쓰레기통 아이콘으로 항목 삭제 */} + {option.trim() === '' && index >= 2 && ( + handleRemoveOption(index)}> + 항목 삭제 + + )} + )} ))} - {options.length < maxOptions && ( + {!isEditMode && options.length < maxOptions && ( 항목 추가 )} diff --git a/src/components/recordwrite/PageRangeSection.styled.ts b/src/components/recordwrite/PageRangeSection.styled.ts index f4191a1b..479dc1ba 100644 --- a/src/components/recordwrite/PageRangeSection.styled.ts +++ b/src/components/recordwrite/PageRangeSection.styled.ts @@ -52,7 +52,7 @@ export const PageInput = styled.input<{ inputLength?: number }>` font-family: ${typography.fontFamily.primary}; width: ${props => { if (!props.inputLength) return '12px'; - return `${Math.max(9, props.inputLength * 8)}px`; + return `${Math.max(9, props.inputLength * 9)}px`; }}; padding: 0; margin: 0; diff --git a/src/components/recordwrite/PageRangeSection.tsx b/src/components/recordwrite/PageRangeSection.tsx index fd8d7692..3e69a515 100644 --- a/src/components/recordwrite/PageRangeSection.tsx +++ b/src/components/recordwrite/PageRangeSection.tsx @@ -35,6 +35,8 @@ interface PageRangeSectionProps { onOverallToggle: () => void; readingProgress: number; isOverviewPossible: boolean; + isDisabled?: boolean; + hideToggle?: boolean; // 총평 토글 버튼 숨김 여부 } const PageRangeSection = ({ @@ -45,6 +47,8 @@ const PageRangeSection = ({ isOverallEnabled, onOverallToggle, readingProgress, + isDisabled = false, + hideToggle = false, }: PageRangeSectionProps) => { const [hasError, setHasError] = useState(false); const [showRedTooltip, setShowRedTooltip] = useState(false); @@ -112,12 +116,13 @@ const PageRangeSection = ({ onChange={handleInputChange} inputMode="numeric" inputLength={pageRange.length || lastRecordedPage.toString().length} + disabled={isDisabled} /> /{totalPages}p )} - {!isOverallEnabled && pageRange && ( + {!isOverallEnabled && pageRange && !isDisabled && ( 지우기 @@ -148,21 +153,23 @@ const PageRangeSection = ({ )} - - - - 정보 - - 총평 - - - - - + {!hideToggle && ( + + + + 정보 + + 총평 + + + + + + )}
); diff --git a/src/components/recordwrite/RecordContentSection.tsx b/src/components/recordwrite/RecordContentSection.tsx index 5bd53389..15b55411 100644 --- a/src/components/recordwrite/RecordContentSection.tsx +++ b/src/components/recordwrite/RecordContentSection.tsx @@ -10,9 +10,14 @@ import { interface RecordContentSectionProps { content: string; onContentChange: (value: string) => void; + autoFocus?: boolean; // 자동 포커스 및 커서 위치 설정 여부 } -const RecordContentSection = ({ content, onContentChange }: RecordContentSectionProps) => { +const RecordContentSection = ({ + content, + onContentChange, + autoFocus = false, +}: RecordContentSectionProps) => { const maxLength = 500; const textareaRef = useRef(null); @@ -31,10 +36,19 @@ const RecordContentSection = ({ content, onContentChange }: RecordContentSection adjustHeight(); }, [content]); - // 컴포넌트 마운트 시 초기 높이 설정 + // 컴포넌트 마운트 시 초기 높이 설정 및 autoFocus 처리 useEffect(() => { adjustHeight(); - }, []); + + // autoFocus가 true이고 textarea가 있으면 포커스 및 커서를 끝으로 이동 + if (autoFocus && textareaRef.current) { + const textarea = textareaRef.current; + textarea.focus(); + // 커서를 텍스트 끝으로 이동 + const length = textarea.value.length; + textarea.setSelectionRange(length, length); + } + }, [autoFocus]); const handleChange = (e: React.ChangeEvent) => { onContentChange(e.target.value); diff --git a/src/components/search/BookSearchResult.tsx b/src/components/search/BookSearchResult.tsx index 41ce500a..f4a65381 100644 --- a/src/components/search/BookSearchResult.tsx +++ b/src/components/search/BookSearchResult.tsx @@ -104,6 +104,7 @@ const ResultHeader = styled.div` const Cover = styled.img` width: 80px; height: 107px; + flex-shrink: 0; object-fit: cover; `; @@ -111,12 +112,20 @@ const BookInfo = styled.div` display: flex; flex-direction: column; margin-left: 12px; + min-width: 0; `; const Title = styled.h3` font-size: ${typography.fontSize.base}; font-weight: ${typography.fontWeight.semibold}; color: ${colors.white}; + line-height: 20px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; `; const Subtitle = styled.span` @@ -124,6 +133,7 @@ const Subtitle = styled.span` font-weight: ${typography.fontWeight.medium}; color: ${colors.grey[200]}; margin-top: 8px; + line-height: 20px; `; const EmptyWrapper = styled.div` diff --git a/src/components/today-words/MessageList/MessageList.tsx b/src/components/today-words/MessageList/MessageList.tsx index 3f505958..13acdb2d 100644 --- a/src/components/today-words/MessageList/MessageList.tsx +++ b/src/components/today-words/MessageList/MessageList.tsx @@ -3,6 +3,7 @@ import moreIcon from '../../../assets/common/more.svg'; import type { Message } from '../../../types/today'; import MessageActionBottomSheet from './MessageActionBottomSheet'; import { usePopupActions } from '../../../hooks/usePopupActions'; +import { deleteDailyGreeting } from '../../../api/rooms/deleteDailyGreeting'; import { MessageList as StyledMessageList, DateGroup, @@ -24,6 +25,7 @@ interface MessageListProps { currentUserId?: string; onMessageDelete?: (messageId: string) => void; isRealTimeMode?: boolean; + roomId?: number; } export interface MessageListRef { @@ -35,6 +37,8 @@ const MessageList = forwardRef( { messages: initialMessages, currentUserId = 'user.01', + onMessageDelete, + roomId, }, ref, ) => { @@ -100,12 +104,51 @@ const MessageList = forwardRef( setSelectedMessageId(null); }; - const handleDelete = () => { - if (selectedMessageId) { - // TODO: 실제 삭제 API 연동 필요 - console.log(`메시지 ID ${selectedMessageId} 삭제 요청 (API 개발 대기중)`); + const handleDelete = async () => { + if (selectedMessageId && roomId) { + try { + // API에서는 attendanceCheckId가 필요하므로 selectedMessageId를 사용 + const attendanceCheckId = parseInt(selectedMessageId); + + const result = await deleteDailyGreeting(roomId, attendanceCheckId); + + if (result.isSuccess) { + // 로컬 상태에서 메시지 제거 + setMessages(prevMessages => + prevMessages.filter(msg => msg.id !== selectedMessageId) + ); + + // 부모 컴포넌트에 삭제 알림 + if (onMessageDelete) { + onMessageDelete(selectedMessageId); + } + + openSnackbar({ + message: '오늘의 한마디가 삭제되었습니다.', + variant: 'top', + isError: false, + onClose: () => {}, + }); + } else { + openSnackbar({ + message: result.message || '삭제에 실패했습니다.', + variant: 'top', + isError: true, + onClose: () => {}, + }); + } + } catch (error) { + console.error('삭제 오류:', error); + openSnackbar({ + message: '삭제 중 오류가 발생했습니다.', + variant: 'top', + isError: true, + onClose: () => {}, + }); + } + } else if (!roomId) { openSnackbar({ - message: '삭제 기능은 현재 개발 중입니다.', + message: '방 정보가 없습니다.', variant: 'top', isError: true, onClose: () => {}, diff --git a/src/data/userData.ts b/src/data/userData.ts index 2730b292..692b37af 100644 --- a/src/data/userData.ts +++ b/src/data/userData.ts @@ -2,7 +2,7 @@ import type { UserProfileItemProps } from '@/types/user'; export const mockUserList: UserProfileItemProps[] = [ { - profileImgUrl: 'https://placehold.co/36x36', + profileImageUrl: 'https://placehold.co/36x36', nickname: 'ThipOther', aliasName: '칭호칭호', aliasColor: '#FF8BAC', @@ -11,7 +11,7 @@ export const mockUserList: UserProfileItemProps[] = [ isFollowing: false, }, { - profileImgUrl: 'https://placehold.co/36x36', + profileImageUrl: 'https://placehold.co/36x36', nickname: '하위', aliasName: '칭호칭호', aliasColor: '#FF8BAC', @@ -20,7 +20,7 @@ export const mockUserList: UserProfileItemProps[] = [ isFollowing: true, }, { - profileImgUrl: 'https://placehold.co/36x36', + profileImageUrl: 'https://placehold.co/36x36', nickname: '책읽으러왔음', aliasName: '공식 인플루언서', aliasColor: '#A0F8E8', @@ -29,7 +29,7 @@ export const mockUserList: UserProfileItemProps[] = [ isFollowing: false, }, { - profileImgUrl: 'https://placehold.co/36x36', + profileImageUrl: 'https://placehold.co/36x36', nickname: 'thip01', aliasName: '작가', aliasColor: '#A0F8E8', diff --git a/src/hooks/useCreateFeed.ts b/src/hooks/useCreateFeed.ts index bff4cd79..6b29eea4 100644 --- a/src/hooks/useCreateFeed.ts +++ b/src/hooks/useCreateFeed.ts @@ -1,5 +1,7 @@ import { useState } from 'react'; import { createFeed, type CreateFeedBody, type CreateFeedResponse } from '@/api/feeds/createFeed'; +import { getPresignedUrl, type PresignedUrlRequest } from '@/api/feeds/getPresignedUrl'; +import { uploadFileToS3 } from '@/api/feeds/uploadToS3'; import { usePopupActions } from './usePopupActions'; interface UseCreateFeedProps { @@ -38,6 +40,8 @@ export const useCreateFeed = (options?: UseCreateFeedProps) => { } } + let uploadedImageUrls: string[] = []; + if (images && images.length > 0) { // 최대 3장 if (images.length > 3) { @@ -67,10 +71,65 @@ export const useCreateFeed = (options?: UseCreateFeedProps) => { }); return { success: false as const }; } + + // Presigned URL 발급 요청 + const presignedRequests: PresignedUrlRequest[] = images.map(file => ({ + extension: file.name.split('.').pop()?.toLowerCase() || 'jpg', + size: file.size, + })); + + try { + const presignedResponse = await getPresignedUrl(presignedRequests); + + if (!presignedResponse.isSuccess || !presignedResponse.data) { + openSnackbar({ + message: presignedResponse.message || 'Presigned URL 발급에 실패했습니다.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } + + // S3에 이미지 업로드 + const uploadPromises = presignedResponse.data.presignedUrls.map( + async (urlData, index) => { + const success = await uploadFileToS3(urlData.presignedUrl, images[index]); + return success ? urlData.fileUrl : null; + } + ); + + const uploadResults = await Promise.all(uploadPromises); + + // 업로드 실패한 파일이 있는지 확인 + if (uploadResults.some(result => result === null)) { + openSnackbar({ + message: '이미지 업로드 중 오류가 발생했습니다.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } + + uploadedImageUrls = uploadResults.filter((url): url is string => url !== null); + } catch (error) { + console.error('이미지 업로드 실패:', error); + openSnackbar({ + message: '이미지 업로드 중 오류가 발생했습니다.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } } // ===== 선검증 끝 ===== - const res: CreateFeedResponse = await createFeed(body, images); + // 피드 생성 요청에 업로드된 이미지 URL 포함 + const feedBody: CreateFeedBody = { + ...body, + ...(uploadedImageUrls.length > 0 && { imageUrls: uploadedImageUrls }), + }; + + const res: CreateFeedResponse = await createFeed(feedBody); if (res.isSuccess) { openSnackbar({ diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 81deca29..5fa26438 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -156,18 +156,6 @@ const Feed = () => { // 토큰 발급 완료 대기 await waitForToken(); - // localStorage에 토큰이 있는지 확인 - const authToken = localStorage.getItem('authToken'); - // 토큰이 없으면 하드코딩된 토큰으로 설정 - if (!authToken) { - // localStorage.setItem( - // 'authToken', - // 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.BSGuoMWlrzc0oKgSJXHEycxdzzY9-e7gD4xh-wSDemc', - // ); - console.log('🔑 하드코딩된 토큰으로 설정했습니다.'); - return; - } - setTabLoading(true); try { diff --git a/src/pages/feed/FollowerListPage.tsx b/src/pages/feed/FollowerListPage.tsx index c4129f26..279a5dba 100644 --- a/src/pages/feed/FollowerListPage.tsx +++ b/src/pages/feed/FollowerListPage.tsx @@ -126,7 +126,7 @@ const FollowerListPage = () => { {userList.map((user, index) => ( { openConfirm({ title: '모임방을 나가시겠어요?', disc: '방을 나가시게 되면\n독서메이트들과의 추억이 사라집니다.', - onConfirm: () => { - console.log('방 나가기 확정'); - openSnackbar({ - message: '나가기 기능은 현재 개발 중입니다.', - variant: 'top', - isError: true, - onClose: () => {}, - }); + onConfirm: async () => { + if (!roomId) return; + + try { + const response = await leaveRoom(parseInt(roomId)); + + if (response.isSuccess) { + openSnackbar({ + message: '모임 나가기를 완료했어요.', + variant: 'top', + isError: false, + onClose: () => {}, + }); + // 모임 홈으로 이동 + navigate('/group', { replace: true }); + } else { + // API에서 반환한 에러 메시지 사용 + openSnackbar({ + message: response.message, + variant: 'top', + isError: true, + onClose: () => {}, + }); + } + } catch (error: unknown) { + console.error('방 나가기 오류:', error); + + // 서버 응답이 있는 경우 메시지 사용, 없으면 기본 메시지 + let errorMessage = '방 나가기 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { response?: { data?: { message?: string } } }; + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } + } + + openSnackbar({ + message: errorMessage, + variant: 'top', + isError: true, + onClose: () => {}, + }); + } }, }); }; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 4d886e1a..fe34062b 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -62,7 +62,9 @@ const Router = () => { } /> } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index c11815f6..fd413215 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -66,7 +66,6 @@ const Memory = () => { // 기록 데이터 const [myRecords, setMyRecords] = useState([]); const [groupRecords, setGroupRecords] = useState([]); - // API에서 받은 페이지 정보 const [totalPages, setTotalPages] = useState(0); const [currentUserPage, setCurrentUserPage] = useState(0); @@ -117,7 +116,7 @@ const Memory = () => { } setIsOverviewEnabled(response.data.isOverviewEnabled); - + // 페이지 정보 설정 (API에서 제공되면) if (response.data.totalPages !== undefined) { setTotalPages(response.data.totalPages); @@ -242,7 +241,7 @@ const Memory = () => { // 독서 진행률 계산 (전체 페이지 대비 현재 페이지 퍼센트) const readingProgress = totalPages > 0 ? Math.round((currentUserPage / totalPages) * 100) : 0; - + // 총평 활성화 상태를 읽기 진행률에 따라 표시용으로 활용 const overviewStatus = isOverviewEnabled ? '총평 활성화' : '총평 비활성화'; console.log('📊 현재 상태:', overviewStatus, `진행률: ${readingProgress}%`); @@ -300,7 +299,7 @@ const Memory = () => { onClose={() => setShowSnackbar(false)} /> )} - + ); diff --git a/src/pages/mypage/SavePage.tsx b/src/pages/mypage/SavePage.tsx index 82022c2d..df2629cc 100644 --- a/src/pages/mypage/SavePage.tsx +++ b/src/pages/mypage/SavePage.tsx @@ -358,7 +358,7 @@ const BookItem = styled.div` margin: 0 auto; display: flex; border-bottom: 1px solid ${colors.darkgrey.dark}; - padding: 12px 20px; + padding: 12px; &:last-child { border-bottom: none; diff --git a/src/pages/pollwrite/PollWrite.tsx b/src/pages/pollwrite/PollWrite.tsx index 197676c1..914b273c 100644 --- a/src/pages/pollwrite/PollWrite.tsx +++ b/src/pages/pollwrite/PollWrite.tsx @@ -1,18 +1,23 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import PageRangeSection from '../../components/recordwrite/PageRangeSection'; import PollCreationSection from '../../components/pollwrite/PollCreationSection'; import leftArrow from '../../assets/common/leftArrow.svg'; import { Container } from './PollWrite.styled'; import { createVote } from '../../api/record/createVote'; -import type { CreateVoteRequest } from '../../types/record'; +import { updateVote } from '../../api/record/updateVote'; +import type { CreateVoteRequest, UpdateVoteRequest } from '../../types/record'; import { getBookPage } from '../../api/rooms/getBookPage'; import { usePopupActions } from '../../hooks/usePopupActions'; const PollWrite = () => { const navigate = useNavigate(); - const { roomId } = useParams<{ roomId: string }>(); + const { roomId, voteId } = useParams<{ roomId: string; voteId: string }>(); + const [searchParams] = useSearchParams(); + + // 수정 모드인지 판단 + const isEditMode = Boolean(voteId); const { openSnackbar } = usePopupActions(); const [pageRange, setPageRange] = useState(''); @@ -27,9 +32,9 @@ const PollWrite = () => { const [isOverviewPossible, setIsOverviewPossible] = useState(false); const [isLoading, setIsLoading] = useState(true); - // 컴포넌트 마운트 시 책 페이지 정보 조회 + // 컴포넌트 마운트 시 책 페이지 정보 조회 (생성 모드) 또는 투표 내용 로드 (수정 모드) useEffect(() => { - const fetchBookPageInfo = async () => { + const initializeData = async () => { if (!roomId) { openSnackbar({ message: '방 정보를 찾을 수 없습니다.', @@ -42,6 +47,46 @@ const PollWrite = () => { try { setIsLoading(true); + + if (isEditMode) { + // 수정 모드: 쿼리 파라미터에서 기존 내용과 페이지 정보, 투표 옵션 로드 + const existingContent = searchParams.get('content'); + const existingPageRange = searchParams.get('pageRange'); + const existingRecordType = searchParams.get('recordType'); + const existingOptions = searchParams.get('options'); + + if (existingContent) { + setPollContent(decodeURIComponent(existingContent)); + } + + if (existingPageRange) { + setPageRange(existingPageRange); + } + + if (existingRecordType === 'overall') { + setIsOverallEnabled(true); + } + + if (existingOptions) { + try { + const options = JSON.parse(decodeURIComponent(existingOptions)); + setPollOptions(options); + } catch (e) { + console.error('투표 옵션 파싱 오류:', e); + } + } + + // 수정 모드에서도 전체 페이지 수는 필요하므로 책 정보 조회 + const response = await getBookPage(parseInt(roomId)); + if (response.isSuccess) { + setTotalPages(response.data.totalBookPage); + } + + setIsLoading(false); + return; + } + + // 생성 모드: 책 페이지 정보 조회 const response = await getBookPage(parseInt(roomId)); if (response.isSuccess) { @@ -56,9 +101,9 @@ const PollWrite = () => { }); } } catch (error) { - console.error('책 페이지 정보 조회 오류:', error); + console.error('데이터 로드 오류:', error); - let errorMessage = '책 정보를 불러오는 중 오류가 발생했습니다.'; + let errorMessage = '데이터를 불러오는 중 오류가 발생했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { @@ -91,8 +136,8 @@ const PollWrite = () => { } }; - fetchBookPageInfo(); - }, [roomId]); + initializeData(); + }, [roomId, isEditMode]); // 총평 모드가 변경될 때 isOverviewPossible 체크 useEffect(() => { @@ -116,47 +161,92 @@ const PollWrite = () => { setIsSubmitting(true); try { - // 투표 옵션 필터링 (빈 옵션 제거) - const validOptions = pollOptions.filter(option => option.trim() !== ''); + if (isEditMode) { + // 수정 모드: 내용만 수정 + if (!voteId) { + openSnackbar({ + message: '투표 정보를 찾을 수 없습니다.', + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + return; + } - if (validOptions.length < 2) { - openSnackbar({ - message: '투표 옵션은 최소 2개 이상이어야 합니다.', - variant: 'top', - onClose: () => {}, - }); - setIsSubmitting(false); - return; - } + const updateData: UpdateVoteRequest = { + content: pollContent.trim(), + }; + + console.log('투표 수정 API 호출:', updateData); + console.log('roomId:', roomId, 'voteId:', voteId); + + const response = await updateVote(parseInt(roomId), parseInt(voteId), updateData); - // 페이지 범위 결정 - let finalPage: number; + if (response.isSuccess) { + console.log('투표 수정 성공:', response.data); + + openSnackbar({ + message: '투표 수정을 완료했어요.', + variant: 'top', + onClose: () => {}, + }); - if (isOverallEnabled) { - // 총평인 경우: 책의 마지막 페이지 또는 전체 페이지 수 사용 - finalPage = totalPages; + // 성공 시 기록장으로 이동 + navigate(`/rooms/${roomId}/memory`, { + replace: true, + }); + } else { + console.error('투표 수정 실패:', response.message); + openSnackbar({ + message: response.message || '투표 수정에 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + } } else { - // 일반 투표인 경우 - if (pageRange.trim() !== '') { - finalPage = parseInt(pageRange.trim()); + // 생성 모드: 기존 로직 유지 + // 투표 옵션 필터링 (빈 옵션 제거) + const validOptions = pollOptions.filter(option => option.trim() !== ''); + + if (validOptions.length < 2) { + openSnackbar({ + message: '투표 옵션은 최소 2개 이상이어야 합니다.', + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + return; + } + + // 페이지 범위 결정 + let finalPage: number; + + if (isOverallEnabled) { + // 총평인 경우: 책의 마지막 페이지 또는 전체 페이지 수 사용 + finalPage = totalPages; } else { - finalPage = lastRecordedPage; + // 일반 투표인 경우 + if (pageRange.trim() !== '') { + finalPage = parseInt(pageRange.trim()); + } else { + finalPage = lastRecordedPage; + } } - } - // 페이지 유효성 검사 - if (finalPage <= 0 || finalPage > totalPages) { - openSnackbar({ - message: `유효하지 않은 페이지입니다. (1-${totalPages} 사이의 값을 입력해주세요)`, - variant: 'top', - onClose: () => {}, - }); - setIsSubmitting(false); - return; - } + // 페이지 유효성 검사 + if (finalPage <= 0 || finalPage > totalPages) { + openSnackbar({ + message: `유효하지 않은 페이지입니다. (1-${totalPages} 사이의 값을 입력해주세요)`, + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + return; + } - // API 요청 데이터 생성 - const voteData: CreateVoteRequest = { + // API 요청 데이터 생성 + const voteData: CreateVoteRequest = { page: finalPage, isOverview: isOverallEnabled, content: pollContent.trim(), @@ -186,11 +276,12 @@ const PollWrite = () => { }); setIsSubmitting(false); } + } } catch (error) { console.error('투표 저장 실패:', error); // 에러 타입에 따른 메시지 처리 - let errorMessage = '투표 저장 중 오류가 발생했습니다.'; + let errorMessage = isEditMode ? '투표 수정 중 오류가 발생했습니다.' : '투표 저장 중 오류가 발생했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { @@ -228,7 +319,7 @@ const PollWrite = () => { <> } - title="투표 작성" + title={isEditMode ? "투표 수정" : "투표 작성"} onLeftClick={handleBackClick} /> @@ -252,7 +343,7 @@ const PollWrite = () => { <> } - title="투표 작성" + title={isEditMode ? "투표 수정" : "투표 작성"} rightButton={
완료
} onLeftClick={handleBackClick} onRightClick={handleCompleteClick} @@ -268,12 +359,16 @@ const PollWrite = () => { onOverallToggle={() => setIsOverallEnabled(prev => !prev)} readingProgress={isOverviewPossible ? 80 : 70} // 총평 가능하면 80% 이상으로 표시 isOverviewPossible={isOverviewPossible} + isDisabled={isEditMode} // 수정 모드일 때 비활성화 + hideToggle={isEditMode} // 수정 모드일 때 총평 토글 숨김 />
diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index 10c6bc84..a810e480 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -101,7 +101,7 @@ const CreatePost = () => { const candidates = makeIsbnCandidates(selectedBook!.isbn); - // images: 선택값 (없으면 undefined 전달 → FormData에 미첨부) + // images: 선택값 (없으면 undefined 전달) const filesOrUndefined = selectedPhotos.length ? selectedPhotos : undefined; // 최대 2회까지(총 3회) 재시도: 13 → (10) → (정규화원본) diff --git a/src/pages/recordwrite/RecordWrite.tsx b/src/pages/recordwrite/RecordWrite.tsx index a3f9ce98..f233fb0a 100644 --- a/src/pages/recordwrite/RecordWrite.tsx +++ b/src/pages/recordwrite/RecordWrite.tsx @@ -1,18 +1,23 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import PageRangeSection from '../../components/recordwrite/PageRangeSection'; import RecordContentSection from '../../components/recordwrite/RecordContentSection'; import leftArrow from '../../assets/common/leftArrow.svg'; import { Container } from './RecordWrite.styled'; import { createRecord } from '../../api/record/createRecord'; -import type { CreateRecordRequest } from '../../types/record'; +import { updateRecord } from '../../api/record/updateRecord'; +import type { CreateRecordRequest, UpdateRecordRequest } from '../../types/record'; import { getBookPage } from '../../api/rooms/getBookPage'; import { usePopupActions } from '../../hooks/usePopupActions'; const RecordWrite = () => { const navigate = useNavigate(); - const { roomId } = useParams<{ roomId: string }>(); + const { roomId, recordId } = useParams<{ roomId: string; recordId: string }>(); + const [searchParams] = useSearchParams(); + + // 수정 모드인지 판단 + const isEditMode = Boolean(recordId); const { openSnackbar } = usePopupActions(); const [pageRange, setPageRange] = useState(''); @@ -26,9 +31,9 @@ const RecordWrite = () => { const [isOverviewPossible, setIsOverviewPossible] = useState(false); const [isLoading, setIsLoading] = useState(true); - // 컴포넌트 마운트 시 책 페이지 정보 조회 + // 컴포넌트 마운트 시 책 페이지 정보 조회 (생성 모드) 또는 기록 내용 로드 (수정 모드) useEffect(() => { - const fetchBookPageInfo = async () => { + const initializeData = async () => { if (!roomId) { openSnackbar({ message: '방 정보를 찾을 수 없습니다.', @@ -41,6 +46,36 @@ const RecordWrite = () => { try { setIsLoading(true); + + if (isEditMode) { + // 수정 모드: 쿼리 파라미터에서 기존 내용과 페이지 정보 로드 + const existingContent = searchParams.get('content'); + const existingPageRange = searchParams.get('pageRange'); + const existingRecordType = searchParams.get('recordType'); + + if (existingContent) { + setContent(decodeURIComponent(existingContent)); + } + + if (existingPageRange) { + setPageRange(existingPageRange); + } + + if (existingRecordType === 'overall') { + setIsOverallEnabled(true); + } + + // 수정 모드에서도 전체 페이지 수는 필요하므로 책 정보 조회 + const response = await getBookPage(parseInt(roomId)); + if (response.isSuccess) { + setTotalPages(response.data.totalBookPage); + } + + setIsLoading(false); + return; + } + + // 생성 모드: 책 페이지 정보 조회 const response = await getBookPage(parseInt(roomId)); if (response.isSuccess) { @@ -55,9 +90,7 @@ const RecordWrite = () => { }); } } catch (error) { - console.error('책 페이지 정보 조회 오류:', error); - - let errorMessage = '책 정보를 불러오는 중 오류가 발생했습니다.'; + let errorMessage = '데이터를 불러오는 중 오류가 발생했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { @@ -90,8 +123,8 @@ const RecordWrite = () => { } }; - fetchBookPageInfo(); - }, [roomId]); + initializeData(); + }, [roomId, isEditMode]); // 총평 모드가 변경될 때 isOverviewPossible 체크 useEffect(() => { @@ -115,67 +148,100 @@ const RecordWrite = () => { setIsSubmitting(true); try { - // 페이지 범위 결정 - let finalPage: number; + if (isEditMode) { + // 수정 모드: 내용만 수정 + if (!recordId) { + openSnackbar({ + message: '기록 정보를 찾을 수 없습니다.', + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + return; + } - if (isOverallEnabled) { - // 총평인 경우: 책의 마지막 페이지 또는 전체 페이지 수 사용 - finalPage = totalPages; - } else { - // 일반 기록인 경우 - if (pageRange.trim() !== '') { - finalPage = parseInt(pageRange.trim()); + const updateData: UpdateRecordRequest = { + content: content.trim(), + }; + + const response = await updateRecord(parseInt(roomId), parseInt(recordId), updateData); + + if (response.isSuccess) { + openSnackbar({ + message: '기록 수정을 완료했어요.', + variant: 'top', + onClose: () => {}, + }); + + // 성공 시 기록장으로 이동 + navigate(`/rooms/${roomId}/memory`, { + replace: true, + }); } else { - finalPage = lastRecordedPage; + openSnackbar({ + message: response.message || '기록 수정에 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); } - } - - // 페이지 유효성 검사 - if (finalPage <= 0 || finalPage > totalPages) { - openSnackbar({ - message: `유효하지 않은 페이지입니다. (1-${totalPages} 사이의 값을 입력해주세요)`, - variant: 'top', - onClose: () => {}, - }); - setIsSubmitting(false); - return; - } + } else { + // 생성 모드: 기존 로직 유지 + // 페이지 범위 결정 + let finalPage: number; - // API 요청 데이터 생성 - const recordData: CreateRecordRequest = { - page: finalPage, - isOverview: isOverallEnabled, - content: content.trim(), - }; + if (isOverallEnabled) { + // 총평인 경우: 책의 마지막 페이지 또는 전체 페이지 수 사용 + finalPage = totalPages; + } else { + // 일반 기록인 경우 + if (pageRange.trim() !== '') { + finalPage = parseInt(pageRange.trim()); + } else { + finalPage = lastRecordedPage; + } + } - console.log('기록 생성 API 호출:', recordData); - console.log('roomId:', roomId); + // 페이지 유효성 검사 + if (finalPage <= 0 || finalPage > totalPages) { + openSnackbar({ + message: `유효하지 않은 페이지입니다. (1-${totalPages} 사이의 값을 입력해주세요)`, + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + return; + } - // API 호출 - const response = await createRecord(parseInt(roomId), recordData); + // API 요청 데이터 생성 + const recordData: CreateRecordRequest = { + page: finalPage, + isOverview: isOverallEnabled, + content: content.trim(), + }; - if (response.isSuccess) { - console.log('기록 생성 성공:', response.data); + // API 호출 + const response = await createRecord(parseInt(roomId), recordData); - // 성공 시 기록장으로 이동 - navigate(`/rooms/${roomId}/memory`, { - replace: true, - }); - } else { - // API 에러 응답 처리 - console.error('기록 생성 실패:', response.message); - openSnackbar({ - message: response.message || '기록 작성에 실패했습니다.', - variant: 'top', - onClose: () => {}, - }); - setIsSubmitting(false); + if (response.isSuccess) { + // 성공 시 기록장으로 이동 + navigate(`/rooms/${roomId}/memory`, { + replace: true, + }); + } else { + openSnackbar({ + message: response.message || '기록 작성에 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + } } } catch (error) { - console.error('기록 저장 실패:', error); - // 에러 타입에 따른 메시지 처리 - let errorMessage = '기록 저장 중 오류가 발생했습니다.'; + let errorMessage = isEditMode + ? '기록 수정 중 오류가 발생했습니다.' + : '기록 저장 중 오류가 발생했습니다.'; if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { @@ -192,9 +258,9 @@ const RecordWrite = () => { } else if (axiosError.response?.data?.code === 400) { errorMessage = '입력값을 확인해 주세요.'; } else if (axiosError.response?.data?.code === 403) { - errorMessage = '방 접근 권한이 없습니다.'; + errorMessage = '접근 권한이 없습니다.'; } else if (axiosError.response?.data?.code === 404) { - errorMessage = '존재하지 않는 방입니다.'; + errorMessage = '존재하지 않는 데이터입니다.'; } } @@ -213,7 +279,7 @@ const RecordWrite = () => { <> } - title="기록 작성" + title={isEditMode ? '기록 수정' : '기록 작성'} onLeftClick={handleBackClick} /> @@ -237,7 +303,7 @@ const RecordWrite = () => { <> } - title="기록 작성" + title={isEditMode ? '기록 수정' : '기록 작성'} rightButton={
완료
} onLeftClick={handleBackClick} onRightClick={handleCompleteClick} @@ -253,8 +319,14 @@ const RecordWrite = () => { onOverallToggle={() => setIsOverallEnabled(prev => !prev)} readingProgress={isOverviewPossible ? 80 : 70} // 총평 가능하면 80% 이상으로 표시 isOverviewPossible={isOverviewPossible} + isDisabled={isEditMode} // 수정 모드일 때 비활성화 + hideToggle={isEditMode} // 수정 모드일 때 총평 토글 숨김 + /> + -
); diff --git a/src/pages/searchBook/SearchBook.styled.ts b/src/pages/searchBook/SearchBook.styled.ts index ab333261..6b273ed1 100644 --- a/src/pages/searchBook/SearchBook.styled.ts +++ b/src/pages/searchBook/SearchBook.styled.ts @@ -175,6 +175,10 @@ export const FeedSection = styled.section` margin-top: 20px; `; +export const FeedPostContainer = styled.div` + min-height: 50vh; +`; + export const FeedTitle = styled.h2` color: ${colors.white}; font-size: ${typography.fontSize.lg}; diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index ff362416..992772c0 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -20,6 +20,7 @@ import { EmptyState, EmptyTitle, EmptySubText, + FeedPostContainer, } from './SearchBook.styled'; import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; @@ -298,11 +299,10 @@ const SearchBook = () => { setSelectedFilter={filter => setSelectedFilter(filter as (typeof FILTER)[number])} /> - {isLoadingFeeds && feeds.length === 0 ? ( 불러오는 중... ) : feeds.length > 0 ? ( - <> + {feeds.map((post, idx) => (
{ />
))} - +
) : ( 이 책으로 작성된 피드가 없어요. diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index 193cff0a..589cc1a2 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -296,8 +296,11 @@ const TodayWords = () => { } }, [inputValue, roomId, isSubmitting, openSnackbar, todayMyMessageCount, DAILY_LIMIT]); - - + // 메시지 삭제 핸들러 + const handleMessageDelete = useCallback((messageId: string) => { + // 삭제된 메시지를 로컬 상태에서 제거 (MessageList 컴포넌트에서 이미 처리하지만 동기화를 위해) + setMessages(prev => prev.filter(msg => msg.id !== messageId)); + }, []); return ( <> @@ -319,6 +322,8 @@ const TodayWords = () => { {isLoadingMore && (
diff --git a/src/types/record.ts b/src/types/record.ts index 6b832a5f..878e68a9 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -51,6 +51,26 @@ export interface VoteData { voteItems: VoteItemResult[]; // 투표 결과 리스트 } +// 기록 수정 요청 데이터 타입 +export interface UpdateRecordRequest { + content: string; // 수정할 기록 내용 +} + +// 기록 수정 응답 데이터 타입 +export interface UpdateRecordData { + roomId: number; // 방 ID +} + +// 투표 수정 요청 데이터 타입 +export interface UpdateVoteRequest { + content: string; // 수정할 투표 내용 +} + +// 투표 수정 응답 데이터 타입 +export interface UpdateVoteData { + roomId: number; // 방 ID +} + // 공통 API 응답 타입 export interface ApiResponse { isSuccess: boolean; diff --git a/src/types/user.ts b/src/types/user.ts index dd08b47f..03ec1185 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,7 +1,7 @@ export type UserProfileType = 'followlist' | 'followerlist'; export interface UserProfileItemProps { - profileImgUrl: string; + profileImageUrl: string; nickname: string; aliasName: string; aliasColor?: string;