-
Notifications
You must be signed in to change notification settings - Fork 1
feature: 인증사진 임의 업로드 기능 구현 및 게시글에 프로필 이미지, 사용자 닉네임 연결 #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -10,32 +10,33 @@ export type CreatePostReq = { | |||||
| type: ServerPostType; | ||||||
| title: string; | ||||||
| content: string; | ||||||
| imageUrls?: string[]; | ||||||
| imageUrl: string; // 단일 이미지 URL | ||||||
| routeId?: number; | ||||||
| }; | ||||||
|
|
||||||
| 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 }; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기 imageUrl이 왜 undefined일까요? |
||||||
|
|
||||||
| 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 ''; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. null이 더 어울리지 않을까요? 실제 위 케이스가 아닌 경우가 없을꺼라는것을 명시적이여야 한다고 생각합니다. |
||||||
| } | ||||||
|
|
||||||
| /** 목록 아이템 매퍼 (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<Post> { | ||||||
| const { data } = await api.post<PostRes>('/community/posts', { | ||||||
| ...body, | ||||||
| imageUrls: body.imageUrls ?? [], | ||||||
| }); | ||||||
| const { data } = await api.post<PostRes>('/community/posts', body); | ||||||
| return mapPostResToEntity(data); | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -178,26 +170,18 @@ export async function getPosts(query: GetPostsQuery = {}): Promise<Post[]> { | |||||
| 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}`); | ||||||
|
Comment on lines
+173
to
+176
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이쪽에서 왜 type에 따라 조건문이 필요한 이유가 무엇인가요? |
||||||
|
|
||||||
| 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; | ||||||
| }; | ||||||
|
Comment on lines
+286
to
+291
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 결국 File을 넘기는거 아닌가요? |
||||||
|
|
||||||
| export type PresignRes = { | ||||||
| /** PUT 대상 URL (S3 presigned URL 등) */ | ||||||
| url: string; | ||||||
| }; | ||||||
|
|
||||||
| export async function getProofPresign(req: PresignReq): Promise<PresignRes> { | ||||||
| const { data } = await api.post<PresignRes>('/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<string> { | ||||||
| const contentType = file.type || 'image/jpeg'; | ||||||
| const size = file.size; | ||||||
|
|
||||||
| // 1) presign | ||||||
| const presign = await getProofPresign({ | ||||||
| type: 'verify', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 스타일 가이드에 따르면 매직 넘버나 문자열은 이름 있는 상수로 관리해야 합니다.1
Suggested change
Style Guide ReferencesFootnotes
|
||||||
| 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); | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() { | |
| /> | ||
|
|
||
| <Content> | ||
| {images.length > 0 && ( | ||
| <ImageList> | ||
| {images.map((src, idx) => ( | ||
| <li key={`${src}-${idx}`}> | ||
| <img src={src} alt={`${post.title || 'post'}-${idx + 1}`} /> | ||
| </li> | ||
| ))} | ||
| </ImageList> | ||
| {imageUrl && imageUrl !== '{}' && ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| <PostImageContainer> | ||
| <PostImage src={imageUrl} alt="post image" /> | ||
| </PostImageContainer> | ||
| )} | ||
|
|
||
| <Article>{post.content || '내용이 없습니다.'}</Article> | ||
|
|
||
| <LikeBar> | ||
|
|
@@ -287,7 +282,6 @@ export default function CommunityDetail() { | |
| </LikeBar> | ||
| </Content> | ||
|
|
||
| {/* ✅ 댓글 목록 + 수정/삭제 핸들러 전달 */} | ||
| <CommentList | ||
| comments={comments} | ||
| onEdit={handleEditComment} | ||
|
|
@@ -322,19 +316,17 @@ const Content = styled.section` | |
| line-height: 1.6; | ||
| `; | ||
|
|
||
| const ImageList = styled.ul` | ||
| list-style: none; | ||
| padding: 0; | ||
| margin: 0 0 12px 0; | ||
| display: grid; | ||
| grid-template-columns: 1fr; | ||
| gap: 8px; | ||
| img { | ||
| width: 100%; | ||
| height: auto; | ||
| border-radius: 8px; | ||
| display: block; | ||
| } | ||
| const PostImageContainer = styled.div` | ||
| width: 100%; | ||
| border-radius: 8px; | ||
| overflow: hidden; | ||
| `; | ||
|
|
||
| const PostImage = styled.img` | ||
| width: 100%; | ||
| aspect-ratio: 1 / 1; | ||
| object-fit: cover; | ||
| display: block; | ||
| `; | ||
|
|
||
| const Article = styled.div` | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ export type EditPatch = Partial<{ | |
| category: Category; | ||
| title: string; | ||
| content: string; | ||
| imageUrls: string[]; | ||
| imageUrl: string; | ||
| routeId: number | undefined; | ||
| }>; | ||
|
|
||
|
|
@@ -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 }); | ||
| } | ||
| }; | ||
|
Comment on lines
+47
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| return ( | ||
| <Body aria-busy={submitting}> | ||
|
|
@@ -69,11 +79,9 @@ export default function EditForm({ | |
| /> | ||
| </Field> | ||
|
|
||
| {showPicker && ( | ||
| {showPicker ? ( | ||
| <Field> | ||
| <Label> | ||
| {category === 'PROOF' ? '인증샷 선택' : '공유할 항목 선택'} | ||
| </Label> | ||
| <Label>공유할 항목 선택</Label> | ||
| <Picker role="listbox" aria-label="선택 리스트"> | ||
| {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} | ||
| > | ||
| <Thumb> | ||
|
|
@@ -98,9 +106,13 @@ export default function EditForm({ | |
| </PickerItem> | ||
| ); | ||
| })} | ||
| {/* SHARE에서는 Picker 내부 하단에 footerSlot */} | ||
| {footerSlot} | ||
| </Picker> | ||
| </Field> | ||
| ) : ( | ||
| // PROOF/FREE/MATE에서는 Picker 대신 별도 영역으로 footerSlot 노출 | ||
| footerSlot && <Field>{footerSlot}</Field> | ||
| )} | ||
|
|
||
| <Field> | ||
|
|
@@ -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` | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
update를 할때는 null이 허용되는데, 생성될때는 null이 허용되지 않습니다.
이게 왜 그런걸까요?