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;