diff --git a/apps/web/src/api/courses.ts b/apps/web/src/api/courses.ts index 3f39f9d..e73e346 100644 --- a/apps/web/src/api/courses.ts +++ b/apps/web/src/api/courses.ts @@ -4,13 +4,40 @@ export type CourseItem = { id: string; imageUrl?: string; title?: string; - date?: string; + date?: string; // createdAt을 여기로 매핑해 줄 거야 + length: number; +}; + +type SearchUserCoursesRes = { + results: Array<{ + id: number; + title: string; + imageUrl: string | null; + createdAt: string; + length: number; + time: number; + distance: number; + // departure, length, time, author, bookmarked, distance 등은 필요 시 추가 + }>; + nextCursor: string | null; }; export async function searchUserCourses(cursor?: string) { - const { data } = await api.get<{ - results: CourseItem[]; - nextCursor: string | null; - }>('/api/courses/search/users', { params: cursor ? { cursor } : undefined }); - return data; + const { data } = await api.get( + '/api/courses/search/users', + { + params: cursor ? { cursor } : undefined, + }, + ); + + return { + results: data.results.map((r) => ({ + id: String(r.id), + imageUrl: r.imageUrl ?? undefined, + title: r.title ?? undefined, + date: r.createdAt ?? undefined, + length: r.length ?? undefined, + })) as CourseItem[], + nextCursor: data.nextCursor ?? null, + }; } diff --git a/apps/web/src/api/posts.ts b/apps/web/src/api/posts.ts index ac25eae..4409064 100644 --- a/apps/web/src/api/posts.ts +++ b/apps/web/src/api/posts.ts @@ -110,13 +110,11 @@ function pickImageUrl(res: { } function mapListItemToEntity(res: PostResListItem | PostResListItemAlt): Post { - const author = res.authorInfo?.nickname; - return { id: String(res.id), category: res.type, title: res.title, - author, + author: 'author', commentsCount: res.commentCount, content: res.content, likeCount: res.likeCount, diff --git a/apps/web/src/pages/Community/CommunityDetail/index.tsx b/apps/web/src/pages/Community/CommunityDetail/index.tsx index cd254e7..12dbb4e 100644 --- a/apps/web/src/pages/Community/CommunityDetail/index.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/index.tsx @@ -13,7 +13,7 @@ import { getReadablePostError, } from '@/api/posts'; import { - getPostComments, // ← 커서 기반 시그니처: (postId, limit?, cursor?) + getPostComments, createPostComment, updateComment, deleteComment, @@ -46,16 +46,13 @@ export default function CommunityDetail() { null, ); - // 현재 사용자 ID (RN → Web 브리지) const myIdRaw = useNativeBridgeStore((s) => s.init?.user?.id ?? null); const toStr = useCallback((v: unknown) => (v == null ? null : String(v)), []); const myId = toStr(myIdRaw); - // 게시글 수정/삭제 권한 (작성자 본인만) const authorId = toStr(post?.authorInfo?.id) ?? null; const canEdit = myId !== null && authorId !== null && myId === authorId; - // ---- 게시글 로드 ---- useEffect(() => { let mounted = true; (async () => { @@ -81,7 +78,6 @@ export default function CommunityDetail() { }; }, [id]); - // ---- 댓글 최초 로드 (커서 기반) ---- useEffect(() => { let mounted = true; (async () => { @@ -104,7 +100,6 @@ export default function CommunityDetail() { }; }, [id, cLimit]); - // ---- 좋아요 ---- const handleToggleLike = async () => { if (!post || liking) return; setLiking(true); @@ -127,7 +122,6 @@ export default function CommunityDetail() { } }; - // ---- 댓글 작성 ---- const handleSubmitComment = async () => { const v = inputRef.current?.value?.trim(); if (!v || !id) return; @@ -139,7 +133,6 @@ export default function CommunityDetail() { setPost((p) => p ? { ...p, commentsCount: (p.commentsCount ?? 0) + 1 } : p, ); - // 커서는 서버가 새로 계산하므로 유지(append만 했으니 nextCursor 변화 없음) } catch (e) { alert(getReadablePostError(e)); } finally { @@ -147,7 +140,6 @@ export default function CommunityDetail() { } }; - // ---- 댓글 수정 ---- const handleEditComment = async (commentId: string) => { const target = comments.find((c) => c.id === commentId); if (!target) return; @@ -186,7 +178,6 @@ export default function CommunityDetail() { } }; - // ---- 게시글 삭제 ---- const handleDeletePost = useCallback(async () => { if (!post || deleting) return; @@ -213,7 +204,6 @@ export default function CommunityDetail() { } }, [post, deleting, nav, confirm]); - // ---- 댓글 삭제 ---- const handleDeleteComment = async (commentId: string) => { const ok = await confirm({ title: '댓글을 삭제하시겠습니까?', @@ -243,9 +233,8 @@ export default function CommunityDetail() { } }; - // ---- 더 보기(커서 페이징) ---- const handleLoadMore = async () => { - if (!post || !cCursor) return; // 더 불러올 것 없으면 종료 + if (!post || !cCursor) return; const { items, nextCursor } = await getPostComments( post.id, cLimit, @@ -255,7 +244,6 @@ export default function CommunityDetail() { setCCursor(nextCursor ?? null); }; - // ---- early returns ---- if (loading) { return ( diff --git a/apps/web/src/pages/Community/CommunityEdit/_components/EditForm.tsx b/apps/web/src/pages/Community/CommunityEdit/_components/EditForm.tsx index 47d1b2c..40929cf 100644 --- a/apps/web/src/pages/Community/CommunityEdit/_components/EditForm.tsx +++ b/apps/web/src/pages/Community/CommunityEdit/_components/EditForm.tsx @@ -106,12 +106,10 @@ export default function EditForm({ ); })} - {/* SHARE에서는 Picker 내부 하단에 footerSlot */} {footerSlot} ) : ( - // PROOF/FREE/MATE에서는 Picker 대신 별도 영역으로 footerSlot 노출 footerSlot && {footerSlot} )} diff --git a/apps/web/src/pages/Community/CommunityEdit/index.tsx b/apps/web/src/pages/Community/CommunityEdit/index.tsx index 2757d5d..5364c79 100644 --- a/apps/web/src/pages/Community/CommunityEdit/index.tsx +++ b/apps/web/src/pages/Community/CommunityEdit/index.tsx @@ -13,8 +13,6 @@ import { updatePostById, uploadProofWithFile, } from '@/api/posts'; -// 🔻 PROOF 목록 불러오기는 제거 (PROOF에선 목록을 안 씀) -// import { fetchRunningRecords } from '@/api/running'; import { searchUserCourses } from '@/api/courses'; const PROOF_KEYS: Exclude[] = ['PROOF']; @@ -32,6 +30,7 @@ type ItemShape = { imageUrl?: string; title?: string; date?: string; + length?: number; }; export default function CommunityEdit() { @@ -55,13 +54,14 @@ export default function CommunityEdit() { const [listDone, setListDone] = useState(false); const [listError, setListError] = useState(null); - // PROOF 업로드 미리보기/업로딩 상태 const [proofFile, setProofFile] = useState(null); const [proofPreview, setProofPreview] = useState(''); const [proofUploading, setProofUploading] = useState(false); const sentinelRef = useRef(null); + const inFlightRef = useRef(false); + useEffect(() => { let mounted = true; (async () => { @@ -93,19 +93,13 @@ export default function CommunityEdit() { if (typeof patch.title === 'string') setTitle(patch.title); if (typeof patch.content === 'string') setContent(patch.content); if (typeof patch.imageUrl === 'string') setImageUrl(patch.imageUrl); - if ( - typeof patch.routeId === 'number' || - typeof patch.routeId === 'undefined' - ) { - setRouteId(patch.routeId); - } }, []); // 카테고리 바뀌면 목록 초기화 + PROOF 미리보기 정리 useEffect(() => { setItems([]); setCursor(null); - setListDone(true); + setListDone(false); setListError(null); if (!isProofCategory(category)) { @@ -115,11 +109,13 @@ export default function CommunityEdit() { } }, [category, proofPreview]); - // 페이지 로드 함수 — 🔸 SHARE에서만 동작 const loadMore = useCallback(async () => { + if (inFlightRef.current) return; if (listLoading || listDone) return; if (!isShareCategory(category)) return; + if (cursor === null && items.length > 0) return; + inFlightRef.current = true; setListLoading(true); setListError(null); try { @@ -133,6 +129,7 @@ export default function CommunityEdit() { imageUrl: c.imageUrl, title: c.title ?? '코스 공유', date: c.date, + length: c.length, })), ]); setCursor(nextCursor); @@ -146,10 +143,10 @@ export default function CommunityEdit() { setListError(msg); } finally { setListLoading(false); + inFlightRef.current = false; } - }, [category, cursor, listDone, listLoading]); + }, [category, cursor, listDone, listLoading, items.length]); - // 초기 로드 — 🔸 SHARE에서만 useEffect(() => { if ( isShareCategory(category) && @@ -171,7 +168,7 @@ export default function CommunityEdit() { const io = new IntersectionObserver( (entries) => { const isVisible = entries.some((e) => e.isIntersecting); - if (isVisible && !listLoading) { + if (isVisible && !listLoading && !inFlightRef.current) { void loadMore(); } }, @@ -199,14 +196,31 @@ export default function CommunityEdit() { [proofPreview], ); + const handleSelectCourse = useCallback( + (clickedId: string) => { + const found = items.find((it) => it.id === clickedId); + const raw = found?.id ?? clickedId; + const n = Number(raw); + if (!Number.isFinite(n)) return; + + setRouteId(n); + + const nextImg = found?.imageUrl?.trim(); + if (nextImg) setImageUrl(nextImg); + }, + [items], + ); + const submit = async () => { if (!title.trim()) return alert('제목을 입력하세요.'); if (!content.trim()) return alert('내용을 입력하세요.'); + if (isShareCategory(category) && typeof routeId !== 'number') { + return alert('공유할 코스를 선택해주세요.'); + } try { setSubmitting(true); - // PROOF면, 아직 업로드 안 된 로컬 파일을 선 업로드 → imageUrl 대체 let finalImageUrl = imageUrl; if (isProofCategory(category) && proofFile) { setProofUploading(true); @@ -222,7 +236,7 @@ export default function CommunityEdit() { type: category, title, content, - imageUrl: finalImageUrl || null, // 비웠다면 null 전달 가능 + imageUrl: finalImageUrl || null, ...(typeof routeId === 'number' ? { routeId } : {}), }); navigate(`/community/${id}`, { replace: true }); @@ -233,7 +247,7 @@ export default function CommunityEdit() { type: category, title, content, - imageUrl: finalImageUrl, // 생성은 필수 + imageUrl: finalImageUrl, routeId, }); navigate(`/community/${created.id}`, { replace: true }); @@ -248,7 +262,7 @@ export default function CommunityEdit() { () => items.map((it) => ({ id: it.id, - imageUrl: it.imageUrl ?? 'https://picsum.photos/200?blur', + imageUrl: it.imageUrl ?? '', title: it.title ?? '', date: it.date ?? '', })), @@ -271,15 +285,17 @@ export default function CommunityEdit() { category={category} title={title} content={content} - // 🔸 PROOF일 땐 EditForm에 빈 배열 전달(픽커 완전 비활성화) items={isShareCategory(category) ? formItems : []} submitting={submitting} submitLabel={editing ? '수정하기' : '작성하기'} onChange={handleFormChange} onSubmit={submit} + selectedItemId={routeId != null ? String(routeId) : null} + onSelectItem={ + isShareCategory(category) ? handleSelectCourse : undefined + } footerSlot={ - {/* 🔹 PROOF: 업로드 카드만 노출 */} {isProofCategory(category) && ( )} - {/* 🔹 SHARE: 목록 관련 UI만 노출 */} {isShareCategory(category) && ( <> - {listError && ( - - {listError} - { - void loadMore(); - }} - disabled={listLoading} - > - 다시 시도 - - + {!listDone && !listLoading && ( + )} - {!listDone && } {listLoading && 불러오는 중…} {listDone && items.length > 0 && ( 마지막 항목입니다. @@ -367,33 +370,6 @@ const SmallHint = styled.div` color: ${({ theme }) => theme.colors.subtext}; `; -const ErrorBox = styled.div` - width: 100%; - display: flex; - gap: 8px; - align-items: center; - justify-content: space-between; - padding: 10px 12px; - border-radius: 12px; - background: ${({ theme }) => theme.colors.danger}22; - border: 1px solid ${({ theme }) => theme.colors.danger}; -`; - -const ErrorText = styled.div` - flex: 1; - font-size: 12px; - color: ${({ theme }) => theme.colors.danger}; -`; - -const RetryButton = styled.button` - padding: 6px 10px; - border-radius: 8px; - border: 1px solid ${({ theme }) => theme.colors.danger}; - background: transparent; - font-size: 12px; - cursor: pointer; -`; - const UploadArea = styled.div` width: 100%; display: flex; @@ -402,7 +378,6 @@ const UploadArea = styled.div` margin-bottom: 8px; `; -/** 업로드 미리보기 박스 (label) */ const PreviewCard = styled.label` width: 100%; border: 1px solid ${({ theme }) => theme.colors.border}; diff --git a/apps/web/src/pages/Community/_components/CommunityNavigator.tsx b/apps/web/src/pages/Community/_components/CommunityNavigator.tsx index 44cab60..895fbd6 100644 --- a/apps/web/src/pages/Community/_components/CommunityNavigator.tsx +++ b/apps/web/src/pages/Community/_components/CommunityNavigator.tsx @@ -4,9 +4,9 @@ import { useCommunityStore } from '@/stores/communityStore'; const PATH_BY_KEY = { home: '/community', - photo: '/community/feed/proof', // ✅ photo -> proof - route: '/community/feed/share', // ✅ route -> share - run: '/community/feed/mate', // ✅ run -> mate + photo: '/community/feed/proof', + route: '/community/feed/share', + run: '/community/feed/mate', } as const; type NavKey = keyof typeof PATH_BY_KEY;