Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 87 additions & 51 deletions apps/web/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,33 @@ export type CreatePostReq = {
type: ServerPostType;
title: string;
content: string;
imageUrls?: string[];
imageUrl: string; // 단일 이미지 URL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update를 할때는 null이 허용되는데, 생성될때는 null이 허용되지 않습니다.
이게 왜 그런걸까요?

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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 & {
Expand All @@ -61,7 +62,7 @@ type UpdatePostRes = {
type: ServerPostType;
title: string;
content: string;
imageUrls: string[];
imageUrl: string; // 서버 표준
routeId?: number;
updatedAt: string;
};
Expand All @@ -78,7 +79,6 @@ export type GetPostsQuery = {

/** 목록 아이템 (author 객체 버전) */
type PostResListItem = PostResBase & {
author: AuthorObj;
content?: string; // 서버가 줄 수도 있어 optional
};

Expand All @@ -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 '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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),
Expand All @@ -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);
}

Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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);
}

/** 커서 기반 목록 */
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

스타일 가이드에 따르면 매직 넘버나 문자열은 이름 있는 상수로 관리해야 합니다.1 'verify'라는 문자열이 하드코딩되어 있습니다. 이 값은 API 요청에 사용되는 중요한 값이므로, constants.ts와 같은 파일에 상수로 정의하여 사용하는 것이 좋습니다. 이렇게 하면 코드의 가독성과 유지보수성이 향상됩니다.

Suggested change
type: 'verify',
type: 'verify', // TODO: 상수로 분리

Style Guide References

Footnotes

  1. 상수는 하드코딩하지 말고 constants.ts 또는 theme.ts에 관리해야 합니다.

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
Expand Up @@ -29,7 +29,7 @@ export default function PostHeader({
</ButtonContainer>
</Row>
<Title>{post.title}</Title>
<Author>{post.author}</Author>
<Author>{post.authorInfo?.nickname}</Author>
</Container>
);
}
Expand Down
40 changes: 16 additions & 24 deletions apps/web/src/pages/Community/CommunityDetail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ export default function CommunityDetail() {
);
}

const images = (post.imageUrls ?? []).filter(Boolean);
const imageUrl = post.imageUrl;
const hasMore = comments.length < cTotal;

return (
Expand All @@ -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 !== '{}' && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

imageUrl !== '{}'와 같은 확인 로직은 컴포넌트 단이 아닌 API 데이터 매핑 계층에서 처리하는 것이 좋습니다. 서버에서 비정상적인 빈 객체 문자열을 반환하는 경우, api/posts.tspickImageUrl 함수에서 이를 처리하여 컴포넌트는 항상 정제된 imageUrl 값(유효한 URL 문자열 또는 빈 문자열)을 받도록 하는 것이 바람직합니다. 이렇게 하면 UI 컴포넌트의 책임이 명확해지고 코드가 깨끗해집니다.

<PostImageContainer>
<PostImage src={imageUrl} alt="post image" />
</PostImageContainer>
)}

<Article>{post.content || '내용이 없습니다.'}</Article>

<LikeBar>
Expand All @@ -287,7 +282,6 @@ export default function CommunityDetail() {
</LikeBar>
</Content>

{/* ✅ 댓글 목록 + 수정/삭제 핸들러 전달 */}
<CommentList
comments={comments}
onEdit={handleEditComment}
Expand Down Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type EditPatch = Partial<{
category: Category;
title: string;
content: string;
imageUrls: string[];
imageUrl: string;
routeId: number | undefined;
}>;

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

SHARE 카테고리에서 항목을 선택했을 때 routeIdonChange를 통해 업데이트되고, 해당 항목의 imageUrl은 업데이트되지 않고 있습니다. 이로 인해 SHARE 게시글 생성 시 imageUrl이 누락되는 버그가 발생할 수 있습니다.

handlePick 함수에서 onChange를 호출할 때, 선택된 아이템(it)의 imageUrl도 함께 전달하여 상태를 업데이트해야 합니다.

  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,
        imageUrl: it.imageUrl,
      });
    }
  };


return (
<Body aria-busy={submitting}>
Expand Down Expand Up @@ -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) => {
Expand All @@ -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>
Expand All @@ -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>
Expand Down Expand Up @@ -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`
Expand Down
Loading