diff --git a/apps/web/src/api/posts.ts b/apps/web/src/api/posts.ts index 3ed5ab3..de69889 100644 --- a/apps/web/src/api/posts.ts +++ b/apps/web/src/api/posts.ts @@ -10,7 +10,7 @@ export type CreatePostReq = { type: ServerPostType; title: string; content: string; - imageUrls?: string[]; + imageUrl: string; // 단일 이미지 URL routeId?: number; }; @@ -18,24 +18,25 @@ export type UpdatePostReq = Partial<{ type: ServerPostType; title: string; content: string; - imageUrls: string[] | null; + imageUrl: string | null; // 단일 이미지 URL 또는 null routeId: number | null; }>; -type AuthorObj = { id: number; nickname: string; avatarUrl?: string | null }; +type AuthorObj = { id: number; nickname: string; imageUrl?: string | null }; type PostResBase = { id: number; type: ServerPostType; title: string; - imageUrls?: string[]; - imageUrl?: string; + imageUrls?: string[]; // 서버가 과거 호환 위해 줄 수 있음 + imageUrl?: string; // 표준: 단일 routeId?: number | null; likeCount: number; commentCount: number; createdAt: string; updatedAt: string; isDeleted?: boolean; + authorInfo: AuthorObj; }; type PostResCreate = PostResBase & { @@ -61,7 +62,7 @@ type UpdatePostRes = { type: ServerPostType; title: string; content: string; - imageUrls: string[]; + imageUrl: string; // 서버 표준 routeId?: number; updatedAt: string; }; @@ -78,7 +79,6 @@ export type GetPostsQuery = { /** 목록 아이템 (author 객체 버전) */ type PostResListItem = PostResBase & { - author: AuthorObj; content?: string; // 서버가 줄 수도 있어 optional }; @@ -103,21 +103,21 @@ export type GetPostsCursorQuery = { limit?: number; }; +/** 서버 응답에서 단일 imageUrl 계산 (imageUrl 우선, 없으면 imageUrls[0]) */ +function pickImageUrl(res: { + imageUrl?: string; + imageUrls?: string[]; +}): string { + if (typeof res.imageUrl === 'string' && res.imageUrl.length > 0) + return res.imageUrl; + if (Array.isArray(res.imageUrls) && res.imageUrls.length > 0) + return res.imageUrls[0]!; + return ''; +} + /** 목록 아이템 매퍼 (author/authorId 모두 대응) */ function mapListItemToEntity(res: PostResListItem | PostResListItemAlt): Post { - const author = - 'author' in res - ? (res.author?.nickname ?? String(res.author?.id ?? '')) - : String(res.authorId); - - // imageUrls: 서버가 imageUrl(string)만 주면 배열로 포장 - const imgs = ( - Array.isArray(res.imageUrls) && res.imageUrls.length > 0 - ? res.imageUrls - : res.imageUrl - ? [res.imageUrl] - : [] - ) as string[]; + const author = res.authorInfo?.nickname; return { id: String(res.id), @@ -127,44 +127,36 @@ function mapListItemToEntity(res: PostResListItem | PostResListItemAlt): Post { commentsCount: res.commentCount, content: res.content, likeCount: res.likeCount, - imageUrls: imgs, + imageUrl: pickImageUrl(res), createdAt: res.createdAt, updatedAt: res.updatedAt, + authorInfo: res.authorInfo, }; } /** --- 서버 → 클라이언트 엔티티 매퍼 --- */ function mapPostResToEntity(res: PostRes): Post { - // author 문자열: author.nickname(목록/상세) 또는 authorId(생성)로 대체 - const author = - 'author' in res - ? (res.author?.nickname ?? String(res.author?.id ?? '')) - : String((res as PostResCreate).authorId); - - // content: 목록 응답에는 없음 → undefined + const author = res.authorInfo.nickname; const content = 'content' in res ? res.content : undefined; return { id: String(res.id), - category: res.type, // 'FREE' | 'PROOF' | 'SHARE' | 'MATE' + category: res.type, title: res.title, author, commentsCount: res.commentCount, content, likeCount: res.likeCount, - imageUrls: res.imageUrls ?? [], + imageUrl: pickImageUrl(res), createdAt: res.createdAt, updatedAt: res.updatedAt, - // liked: 서버 명세에 없음 → 필요 시 별도 me-상태 API로 보강 + authorInfo: res.authorInfo, }; } /** --- 게시글 생성 --- */ export async function createPost(body: CreatePostReq): Promise { - const { data } = await api.post('/community/posts', { - ...body, - imageUrls: body.imageUrls ?? [], - }); + const { data } = await api.post('/community/posts', body); return mapPostResToEntity(data); } @@ -178,26 +170,18 @@ export async function getPosts(query: GetPostsQuery = {}): Promise { q.push(`type=${encodeURIComponent(query.category as ServerPostType)}`); } - if (typeof query.authorId === 'number') { - q.push(`authorId=${query.authorId}`); - } - if (typeof query.routeId === 'number') { - q.push(`routeId=${query.routeId}`); - } - if (query.sort) { - q.push(`sort=${encodeURIComponent(query.sort)}`); - } - if (typeof query.limit === 'number') { - q.push(`limit=${query.limit}`); - } + if (typeof query.authorId === 'number') q.push(`authorId=${query.authorId}`); + if (typeof query.routeId === 'number') q.push(`routeId=${query.routeId}`); + if (query.sort) q.push(`sort=${encodeURIComponent(query.sort)}`); + if (typeof query.limit === 'number') q.push(`limit=${query.limit}`); const qs = q.length > 0 ? `?${q.join('&')}` : ''; - const { data } = await api.get<{ items: PostResListItem[] }>( - `/community/posts${qs}`, - ); + const { data } = await api.get<{ + items: (PostResListItem | PostResListItemAlt)[]; + }>(`/community/posts${qs}`); - return (data.items ?? []).map(mapPostResToEntity); + return (data.items ?? []).map(mapListItemToEntity); } /** 커서 기반 목록 */ @@ -293,3 +277,55 @@ export function getReadablePostError(e: unknown): string { } return '알 수 없는 오류가 발생했습니다.'; } + +/* ------------------------------------------- + * 🔽 PROOF 이미지 presign & 업로드 유틸 + * (아바타 presign 로직과 동일한 패턴) + * ----------------------------------------- */ + +export type PresignReq = { + /** 예: 'community-proof' */ + type: string; + contentType: string; + size: number; +}; + +export type PresignRes = { + /** PUT 대상 URL (S3 presigned URL 등) */ + url: string; +}; + +export async function getProofPresign(req: PresignReq): Promise { + const { data } = await api.post('/files/presign', req); + return data; +} + +export function objectUrlFromPresign(url: string): string { + return url.split('?')[0]; +} + +/** 파일 업로드(put) 후 public URL(단일) 반환 */ +export async function uploadProofWithFile(file: File): Promise { + const contentType = file.type || 'image/jpeg'; + const size = file.size; + + // 1) presign + const presign = await getProofPresign({ + type: 'verify', + contentType, + size, + }); + + // 2) PUT 업로드 + const putRes = await fetch(presign.url, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body: file, + }); + if (!putRes.ok) { + throw new Error(`이미지 업로드 실패: ${putRes.status}`); + } + + // 3) 업로드된 public URL 반환 + return objectUrlFromPresign(presign.url); +} diff --git a/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx b/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx index 1f67720..d8331eb 100644 --- a/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/_components/PostHeader.tsx @@ -29,7 +29,7 @@ export default function PostHeader({ {post.title} - {post.author} + {post.authorInfo?.nickname} ); } diff --git a/apps/web/src/pages/Community/CommunityDetail/index.tsx b/apps/web/src/pages/Community/CommunityDetail/index.tsx index 5fad91e..86a2fab 100644 --- a/apps/web/src/pages/Community/CommunityDetail/index.tsx +++ b/apps/web/src/pages/Community/CommunityDetail/index.tsx @@ -255,7 +255,7 @@ export default function CommunityDetail() { ); } - const images = (post.imageUrls ?? []).filter(Boolean); + const imageUrl = post.imageUrl; const hasMore = comments.length < cTotal; return ( @@ -267,16 +267,11 @@ export default function CommunityDetail() { /> - {images.length > 0 && ( - - {images.map((src, idx) => ( -
  • - {`${post.title -
  • - ))} -
    + {imageUrl && imageUrl !== '{}' && ( + + + )} -
    {post.content || '내용이 없습니다.'}
    @@ -287,7 +282,6 @@ export default function CommunityDetail() {
    - {/* ✅ 댓글 목록 + 수정/삭제 핸들러 전달 */} ; @@ -41,7 +41,17 @@ export default function EditForm({ submitLabel?: string; footerSlot?: React.ReactNode; }) { - const showPicker = category === 'SHARE' || category === 'PROOF'; + // SHARE에서만 목록 픽커 노출, PROOF/FREE/MATE는 숨김 + const showPicker = category === 'SHARE'; + + const handlePick = (it: SelectableItem) => { + if (submitting) return; + onSelectItem?.(it.id); + if (category === 'SHARE') { + const rid = Number(it.id); + onChange({ routeId: Number.isNaN(rid) ? undefined : rid }); + } + }; return ( @@ -69,11 +79,9 @@ export default function EditForm({ /> - {showPicker && ( + {showPicker ? ( - + {items.length !== 0 && items.map((it) => { @@ -84,7 +92,7 @@ export default function EditForm({ role="option" aria-selected={active} $active={active} - onClick={() => !submitting && onSelectItem?.(it.id)} + onClick={() => handlePick(it)} disabled={submitting} > @@ -98,9 +106,13 @@ export default function EditForm({ ); })} + {/* SHARE에서는 Picker 내부 하단에 footerSlot */} {footerSlot} + ) : ( + // PROOF/FREE/MATE에서는 Picker 대신 별도 영역으로 footerSlot 노출 + footerSlot && {footerSlot} )} @@ -185,6 +197,11 @@ const PickerItem = styled.button<{ $active?: boolean }>` font-size: 18px; color: ${({ theme }) => theme.colors.primary}; } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } `; const Thumb = styled.div` diff --git a/apps/web/src/pages/Community/CommunityEdit/index.tsx b/apps/web/src/pages/Community/CommunityEdit/index.tsx index 8b59681..2757d5d 100644 --- a/apps/web/src/pages/Community/CommunityEdit/index.tsx +++ b/apps/web/src/pages/Community/CommunityEdit/index.tsx @@ -11,8 +11,10 @@ import { getReadablePostError, getPost, updatePostById, + uploadProofWithFile, } from '@/api/posts'; -import { fetchRunningRecords } from '@/api/running'; +// 🔻 PROOF 목록 불러오기는 제거 (PROOF에선 목록을 안 씀) +// import { fetchRunningRecords } from '@/api/running'; import { searchUserCourses } from '@/api/courses'; const PROOF_KEYS: Exclude[] = ['PROOF']; @@ -41,7 +43,7 @@ export default function CommunityEdit() { const [category, setCategory] = useState>('FREE'); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); - const [imageUrls, setImageUrls] = useState([]); + const [imageUrl, setImageUrl] = useState(''); // 단일 이미지 URL const [routeId, setRouteId] = useState(undefined); const [loading, setLoading] = useState(false); @@ -51,6 +53,12 @@ export default function CommunityEdit() { const [cursor, setCursor] = useState(null); const [listLoading, setListLoading] = useState(false); 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); @@ -65,7 +73,7 @@ export default function CommunityEdit() { setCategory(data.category); setTitle(data.title ?? ''); setContent(data.content ?? ''); - setImageUrls(Array.isArray(data.imageUrls) ? data.imageUrls : []); + setImageUrl(typeof data.imageUrl === 'string' ? data.imageUrl : ''); if (typeof data.routeId === 'number') setRouteId(data.routeId); } catch (e) { alert(getReadablePostError(e)); @@ -76,14 +84,15 @@ export default function CommunityEdit() { })(); return () => { mounted = false; + if (proofPreview) URL.revokeObjectURL(proofPreview); }; - }, [editing, id, navigate]); + }, [editing, id, navigate, proofPreview]); const handleFormChange = useCallback((patch: EditPatch) => { if (patch.category && patch.category !== 'ALL') setCategory(patch.category); if (typeof patch.title === 'string') setTitle(patch.title); if (typeof patch.content === 'string') setContent(patch.content); - if (Array.isArray(patch.imageUrls)) setImageUrls(patch.imageUrls); + if (typeof patch.imageUrl === 'string') setImageUrl(patch.imageUrl); if ( typeof patch.routeId === 'number' || typeof patch.routeId === 'undefined' @@ -92,73 +101,71 @@ export default function CommunityEdit() { } }, []); - // 카테고리 바뀌면 목록 초기화 + // 카테고리 바뀌면 목록 초기화 + PROOF 미리보기 정리 useEffect(() => { setItems([]); setCursor(null); setListDone(true); - }, [category]); + setListError(null); + + if (!isProofCategory(category)) { + if (proofPreview) URL.revokeObjectURL(proofPreview); + setProofFile(null); + setProofPreview(''); + } + }, [category, proofPreview]); - // 페이지 로드 함수 + // 페이지 로드 함수 — 🔸 SHARE에서만 동작 const loadMore = useCallback(async () => { if (listLoading || listDone) return; - if (!isProofCategory(category) && !isShareCategory(category)) return; + if (!isShareCategory(category)) return; setListLoading(true); + setListError(null); try { - if (isProofCategory(category)) { - const { results, nextCursor } = await fetchRunningRecords( - cursor ?? undefined, - ); - setItems((prev) => [ - ...prev, - ...results.map((r) => ({ - id: r.id, - imageUrl: r.imageUrl, - title: r.title ?? '러닝 인증', - date: r.date, - })), - ]); - setCursor(nextCursor); - if (!nextCursor || results.length === 0) setListDone(true); - } else if (isShareCategory(category)) { - const { results, nextCursor } = await searchUserCourses( - cursor ?? undefined, - ); - setItems((prev) => [ - ...prev, - ...results.map((c) => ({ - id: c.id, - imageUrl: c.imageUrl, - title: c.title ?? '코스 공유', - date: c.date, - })), - ]); - setCursor(nextCursor); - if (!nextCursor || results.length === 0) setListDone(true); - } + const { results, nextCursor } = await searchUserCourses( + cursor ?? undefined, + ); + setItems((prev) => [ + ...prev, + ...results.map((c) => ({ + id: c.id, + imageUrl: c.imageUrl, + title: c.title ?? '코스 공유', + date: c.date, + })), + ]); + setCursor(nextCursor); + if (!nextCursor || results.length === 0) setListDone(true); } catch (e) { console.error(e); - // 필요시 토스트/알럿 + const msg = + e instanceof Error + ? e.message + : '알 수 없는 오류가 발생했어요. 잠시 후 다시 시도해 주세요.'; + setListError(msg); } finally { setListLoading(false); } }, [category, cursor, listDone, listLoading]); + // 초기 로드 — 🔸 SHARE에서만 useEffect(() => { if ( - (isProofCategory(category) || isShareCategory(category)) && + isShareCategory(category) && !listDone && !listLoading && + !listError && items.length === 0 ) { void loadMore(); } - }, [category, listDone, listLoading, items.length, loadMore]); + }, [category, listDone, listError, listLoading, items.length, loadMore]); + // 무한 스크롤 — 🔸 SHARE에서만 useEffect(() => { if (!sentinelRef.current) return; - if (listDone) return; + if (listDone || listError || !isShareCategory(category)) return; const el = sentinelRef.current; const io = new IntersectionObserver( @@ -176,7 +183,21 @@ export default function CommunityEdit() { ); io.observe(el); return () => io.unobserve(el); - }, [loadMore, listDone, listLoading]); + }, [loadMore, listDone, listError, listLoading, category]); + + // 파일 선택 핸들러 (미리보기, 단일 파일) + const handlePickProofFile = useCallback< + React.ChangeEventHandler + >( + (e) => { + const f = (e.target.files && e.target.files[0]) || null; + if (!f) return; + if (proofPreview) URL.revokeObjectURL(proofPreview); + setProofFile(f); + setProofPreview(URL.createObjectURL(f)); + }, + [proofPreview], + ); const submit = async () => { if (!title.trim()) return alert('제목을 입력하세요.'); @@ -185,12 +206,23 @@ export default function CommunityEdit() { try { setSubmitting(true); + // PROOF면, 아직 업로드 안 된 로컬 파일을 선 업로드 → imageUrl 대체 + let finalImageUrl = imageUrl; + if (isProofCategory(category) && proofFile) { + setProofUploading(true); + try { + finalImageUrl = await uploadProofWithFile(proofFile); + } finally { + setProofUploading(false); + } + } + if (editing && id) { await updatePostById(id, { type: category, title, content, - imageUrls, + imageUrl: finalImageUrl || null, // 비웠다면 null 전달 가능 ...(typeof routeId === 'number' ? { routeId } : {}), }); navigate(`/community/${id}`, { replace: true }); @@ -201,7 +233,7 @@ export default function CommunityEdit() { type: category, title, content, - imageUrls, + imageUrl: finalImageUrl, // 생성은 필수 routeId, }); navigate(`/community/${created.id}`, { replace: true }); @@ -223,6 +255,8 @@ export default function CommunityEdit() { [items], ); + const uploadInputId = 'proof-file-input'; + return ( - {!listDone && } - {listLoading && 불러오는 중…} - {listDone && items.length > 0 && ( - 마지막 항목입니다. - )} - {listDone && items.length === 0 && ( - 표시할 항목이 없습니다 - )} - - ) + + {/* 🔹 PROOF: 업로드 카드만 노출 */} + {isProofCategory(category) && ( + + + + {proofPreview || imageUrl ? ( + <> + + {proofUploading && ( + 이미지 업로드 중… + )} + + ) : ( + 이미지 업로드 + )} + + + )} + + {/* 🔹 SHARE: 목록 관련 UI만 노출 */} + {isShareCategory(category) && ( + <> + {listError && ( + + {listError} + { + void loadMore(); + }} + disabled={listLoading} + > + 다시 시도 + + + )} + {!listDone && } + {listLoading && 불러오는 중…} + {listDone && items.length > 0 && ( + 마지막 항목입니다. + )} + {listDone && items.length === 0 && ( + 표시할 항목이 없습니다 + )} + + )} + } /> @@ -286,3 +366,91 @@ const SmallHint = styled.div` font-size: 12px; 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; + flex-direction: column; + gap: 10px; + margin-bottom: 8px; +`; + +/** 업로드 미리보기 박스 (label) */ +const PreviewCard = styled.label` + width: 100%; + border: 1px solid ${({ theme }) => theme.colors.border}; + border-radius: 12px; + overflow: hidden; + background: ${({ theme }) => theme.colors.surface}; + cursor: pointer; + position: relative; + + &:hover { + outline: 2px dashed ${({ theme }) => theme.colors.border}; + outline-offset: -2px; + } +`; + +const PostImage = styled.img` + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; +`; + +const EmptyState = styled.div` + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + color: ${({ theme }) => theme.colors.subtext}; + ${({ theme }) => theme.typography.body}; + text-align: center; + + strong { + color: ${({ theme }) => theme.colors.text}; + font-weight: 700; + } +`; + +const OverlayHint = styled.div` + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.35); + color: ${({ theme }) => theme.colors.surface}; + font-size: 12px; + font-weight: 600; +`; + +const HiddenInput = styled.input` + display: none; +`; diff --git a/apps/web/src/pages/Community/CommunityList/index.tsx b/apps/web/src/pages/Community/CommunityList/index.tsx index cd693e6..6c18343 100644 --- a/apps/web/src/pages/Community/CommunityList/index.tsx +++ b/apps/web/src/pages/Community/CommunityList/index.tsx @@ -11,7 +11,6 @@ import { getPosts, getReadablePostError } from '@/api/posts'; export default function CommunityList() { const navigate = useNavigate(); - // 섹션별 상태 const [runPosts, setRunPosts] = useState([]); const [photoPosts, setPhotoPosts] = useState([]); const [routePosts, setRoutePosts] = useState([]); @@ -28,13 +27,9 @@ export default function CommunityList() { setError(null); const [mate, proof, share, latest] = await Promise.all([ - // 러닝 메이트 getPosts({ category: 'MATE', limit: 5 }), - // 인증샷 getPosts({ category: 'PROOF', limit: 5 }), - // 경로 공유 getPosts({ category: 'SHARE', limit: 5 }), - // 최신 전체 getPosts({ limit: 5 }), ]); diff --git a/apps/web/src/pages/Community/_components/Postcard.tsx b/apps/web/src/pages/Community/_components/Postcard.tsx index 1c9a866..de0c022 100644 --- a/apps/web/src/pages/Community/_components/Postcard.tsx +++ b/apps/web/src/pages/Community/_components/Postcard.tsx @@ -12,13 +12,13 @@ export default function PostCard({ post }: PostCardProps) { const { id, - author, content, liked, likeCount, commentsCount, imageUrl, createdAt, + authorInfo, } = post; return ( @@ -28,9 +28,9 @@ export default function PostCard({ post }: PostCardProps) { onClick={() => navigate(`/community/${id}`)} > {imageUrl && imageUrl !== '{}' && ( diff --git a/apps/web/src/types/community.ts b/apps/web/src/types/community.ts index cf92984..1688cc8 100644 --- a/apps/web/src/types/community.ts +++ b/apps/web/src/types/community.ts @@ -1,5 +1,7 @@ export type Category = 'ALL' | 'FREE' | 'PROOF' | 'SHARE' | 'MATE'; +type AuthorObj = { id: number; nickname: string; imageUrl?: string | null }; + export interface Post { id: string; category: Exclude; @@ -14,6 +16,7 @@ export interface Post { routeId?: number; createdAt: string; updatedAt: string; + authorInfo: AuthorObj; } export interface Comment {