Skip to content
Open
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
12 changes: 12 additions & 0 deletions docs/agent/agent-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Agent Log

## 2026-08-20 12:10:50 KST

- 책이야기 작성·수정·상세·목록에 최대 5장의 이미지 첨부 및 표시 기능을 연결했습니다.
- 책이야기 댓글·대댓글과 공지 댓글에 이미지 업로드, 편집, 정렬, 확대 보기 기능을 추가했습니다.
- 이미지 업로드 타입·요청/응답 타입·오류 메시지를 백엔드 API 계약에 맞게 확장하고 공지 이미지 업로드 타입을 바로잡았습니다.

## 2026-08-21 16:41:03 KST

- 책이야기 작성·수정 화면의 첨부 버튼명을 `이미지 첨부`로 통일했습니다.
- 댓글·답글 이미지 첨부 버튼을 입력창 내부의 작은 이미지 아이콘으로 변경했습니다.
4 changes: 4 additions & 0 deletions src/app/(admin)/admin/(app)/stories/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Image from "next/image";
import { isValidUrl } from "@/utils/url";
import { useParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/store/useAuthStore";
import ImageGallery from "@/components/common/ImageGallery";

import { useToggleStoryLikeMutation } from "@/hooks/mutations/useStoryMutations";
import { useToggleFollowMutation } from "@/hooks/mutations/useMemberMutations";
Expand Down Expand Up @@ -176,6 +177,9 @@ export default function StoryDetailPage() {
<p className="body_1_3 t:subhead_4 text-Gray-5 mt-4 whitespace-pre-wrap">
{story.description}
</p>
<div className="mt-6">
<ImageGallery imageUrls={story.imageUrls} />
</div>
</div>

<div className="border-t-2 border-Gray-1 w-full max-w-[1040px] mx-auto px-5 mt-10 pt-6 pb-10">
Expand Down
16 changes: 8 additions & 8 deletions src/app/(main)/groups/[id]/admin/notice/[noticeId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,10 @@ export default function EditNoticePage() {
enabled: Number.isFinite(clubId) && isBookshelfModalOpen,
});

const shelves =
shelvesQuery.data?.pages.flatMap((p: any) => p.bookShelfInfoList) ?? [];

const modalBooks: Book[] = useMemo(() => {
return shelves.map((s: any) => ({
const shelves = shelvesQuery.data?.pages.flatMap((page) => page.bookShelfInfoList) ?? [];

return shelves.map((s) => ({
id: s.meetingInfo.meetingId,
title: s.bookInfo.title,
author: s.bookInfo.author,
Expand All @@ -101,7 +100,7 @@ export default function EditNoticePage() {
description: "",
imageUrl: s.bookInfo.imgUrl ?? null,
}));
}, [shelves]);
}, [shelvesQuery.data]);

const { mutateAsync: updateNotice, isPending } = useUpdateClubNoticeMutation();

Expand Down Expand Up @@ -200,6 +199,7 @@ export default function EditNoticePage() {
useEffect(() => {
if (!noticeData || initializedRef.current) return;

// eslint-disable-next-line react-hooks/set-state-in-effect -- fetched notice data initializes the edit form once
setTitle(noticeData.title);
setContent(noticeData.content);
setIsPinned(noticeData.isPinned);
Expand Down Expand Up @@ -375,7 +375,7 @@ export default function EditNoticePage() {
const uploadedImageUrls =
localFiles.length > 0
? await Promise.all(
localFiles.map((file) => imageService.uploadClubImage(file))
localFiles.map((file) => imageService.uploadNoticeImage(file))
)
: [];

Expand All @@ -399,10 +399,10 @@ export default function EditNoticePage() {
toast.dismiss(tid);
toast.success("공지사항 수정 성공");
runWithoutGuard(() => router.push(`/groups/${groupId}/notice/${noticeId}`));
} catch (e: any) {
} catch (e: unknown) {
toast.dismiss(tid);

const msg = e?.message ?? "";
const msg = e instanceof Error ? e.message : "";
if (msg.includes("isPinned") || msg.includes("pinned") || msg.includes("고정")) {
toast.error("고정 공지는 최대 5개까지 가능합니다.");
return;
Expand Down
2 changes: 1 addition & 1 deletion src/app/(main)/groups/[id]/admin/notice/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ export default function NewNoticePage() {
try {
const uploadedImageUrls =
isImageEnabled && imageFiles.length > 0
? await Promise.all(imageFiles.map((f) => imageService.uploadClubImage(f)))
? await Promise.all(imageFiles.map((f) => imageService.uploadNoticeImage(f)))
: [];

const vote: CreateClubNoticeVote | undefined = isVoteEnabled
Expand Down
4 changes: 4 additions & 0 deletions src/app/(main)/stories/[id]/StoryDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@/hooks/mutations/useStoryMutations";
import { useToggleFollowMutation } from "@/hooks/mutations/useMemberMutations";
import { DEFAULT_PROFILE_IMAGE } from "@/constants/images";
import ImageGallery from "@/components/common/ImageGallery";

export default function StoryDetailClient() {
const router = useRouter();
Expand Down Expand Up @@ -155,6 +156,9 @@ export default function StoryDetailClient() {
<p className="body_1_3 t:subhead_4 text-Gray-5 mt-4 whitespace-pre-wrap">
{story.description}
</p>
<div className="mt-6">
<ImageGallery imageUrls={story.imageUrls} />
</div>
</div>
<div
id="comments"
Expand Down
83 changes: 51 additions & 32 deletions src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import BookstoryChoosebook from "@/components/base-ui/BookStory/Editor/bookstory
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { INPUT_LIMITS } from "@/constants/inputLimits";
import { clampTextToLimit, isTextOverLimit } from "@/utils/inputLimit";
import ImageAttachmentPicker from "@/components/common/ImageAttachmentPicker";
import { useImageAttachments } from "@/hooks/useImageAttachments";
import { getErrorMessage, hasErrorCode } from "@/lib/api/errors";

export default function StoryEditPageClient() {
const router = useRouter();
Expand All @@ -20,15 +23,18 @@ export default function StoryEditPageClient() {
const bookStoryId = Number(id);

const { data: story, isLoading, isError } = useStoryDetailQuery(bookStoryId);
const { mutate: updateStory, isPending } = useUpdateBookStoryMutation();
const { mutateAsync: updateStory, isPending } = useUpdateBookStoryMutation();
const { isLoggedIn, isInitialized } = useAuthStore();

const [description, setDescription] = useState("");
const [isDescriptionInitialized, setIsDescriptionInitialized] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const attachments = useImageAttachments([], INPUT_LIMITS.BOOK_STORY_IMAGE_COUNT);
const { reset: resetAttachments } = attachments;
const isDirty = Boolean(
story &&
isDescriptionInitialized &&
description !== story.description
(description !== story.description || attachments.isDirty)
);
const { confirmNavigation, runWithoutGuard } = useUnsavedChangesGuard({
isDirty,
Expand All @@ -39,9 +45,10 @@ export default function StoryEditPageClient() {
useEffect(() => {
if (story && !isDescriptionInitialized) {
setDescription(story.description);
resetAttachments(story.imageUrls ?? []);
setIsDescriptionInitialized(true);
}
}, [story, isDescriptionInitialized]);
}, [story, isDescriptionInitialized, resetAttachments]);

// 로그인 여부 방어
useEffect(() => {
Expand All @@ -63,7 +70,8 @@ export default function StoryEditPageClient() {
confirmNavigation(() => router.back());
};

const handleSubmit = (targetStatus?: "PUBLISHED" | "DRAFT") => {
const handleSubmit = async (targetStatus?: "PUBLISHED" | "DRAFT") => {
if (isUploading || isPending) return;
// PUBLISHED일 경우 description 밸리데이션 처리
if (targetStatus === "PUBLISHED" && !description.trim()) {
toast.error("내용을 입력해 주세요.");
Expand All @@ -79,29 +87,32 @@ export default function StoryEditPageClient() {
return;
}

const payload = {
description,
...(story ? { isbn: story.bookInfo.bookId, title: story.bookStoryTitle } : {}),
...(targetStatus ? { status: targetStatus } : {})
};

updateStory(
{ bookStoryId, data: payload },
{
onSuccess: () => {
if (targetStatus === "DRAFT") {
toast.success("임시저장되었습니다.");
runWithoutGuard(() => router.push("/profile/mypage"));
} else {
toast.success(targetStatus === "PUBLISHED" && story?.status === "DRAFT" ? "발행이 완료되었습니다." : "수정이 완료되었습니다.");
runWithoutGuard(() => router.push(`/stories/${bookStoryId}`));
}
},
onError: () => {
toast.error("저장에 실패했습니다. 다시 시도해 주세요.");
},
setIsUploading(true);
try {
const imageUrls = await attachments.resolveUrls("BOOK_STORY");
const payload = {
description,
imageUrls,
...(story ? { isbn: story.bookInfo.bookId, title: story.bookStoryTitle } : {}),
...(targetStatus ? { status: targetStatus } : {})
};
await updateStory({ bookStoryId, data: payload });
if (targetStatus === "DRAFT") {
toast.success("임시저장되었습니다.");
runWithoutGuard(() => router.push("/profile/mypage"));
} else {
toast.success(targetStatus === "PUBLISHED" && story?.status === "DRAFT" ? "발행이 완료되었습니다." : "수정이 완료되었습니다.");
runWithoutGuard(() => router.push(`/stories/${bookStoryId}`));
}
);
} catch (error) {
toast.error(
hasErrorCode(error) && (error.code === "S3_400" || error.code.startsWith("BOOK_STORY_IMAGE_"))
? getErrorMessage(error.code)
: "저장에 실패했습니다. 다시 시도해 주세요."
);
} finally {
setIsUploading(false);
}
};

if (isLoading) {
Expand Down Expand Up @@ -191,6 +202,14 @@ export default function StoryEditPageClient() {
</div>
</div>

<div className="mx-auto mt-4 w-full max-w-[1040px]">
<ImageAttachmentPicker
controller={attachments}
disabled={isPending || isUploading}
label="이미지 첨부"
/>
</div>

{/* 하단 버튼 */}
<div className="flex justify-center">
<div className="flex w-full max-w-[1040px] justify-center t:justify-end gap-4 mt-6">
Expand All @@ -199,18 +218,18 @@ export default function StoryEditPageClient() {
<button
type="button"
onClick={() => handleSubmit("DRAFT")}
disabled={isPending}
disabled={isPending || isUploading}
className="flex px-4 py-3 w-[132px] h-[44px] justify-center items-center rounded-lg border border-primary-1 text-primary-3 body_1_2 bg-background transition-colors hover:bg-Subbrown-3 disabled:hover:bg-background disabled:opacity-50"
>
{isPending ? "임시저장 중..." : "임시저장"}
{isPending || isUploading ? "임시저장 중..." : "임시저장"}
</button>
<button
type="button"
onClick={() => handleSubmit("PUBLISHED")}
disabled={isPending}
disabled={isPending || isUploading}
className="flex px-4 py-3 w-[132px] h-[44px] justify-center items-center rounded-lg bg-primary-2 text-White body_1_2 hover:bg-primary-1 transition-colors disabled:hover:bg-primary-2 disabled:opacity-50"
>
{isPending ? "발행 중..." : "발행"}
{isPending || isUploading ? "발행 중..." : "발행"}
</button>
</>
) : (
Expand All @@ -225,10 +244,10 @@ export default function StoryEditPageClient() {
<button
type="button"
onClick={() => handleSubmit()}
disabled={isPending}
disabled={isPending || isUploading}
className="flex px-4 py-3 w-[132px] h-[44px] justify-center items-center rounded-lg bg-primary-2 text-White body_1_2 hover:bg-primary-1 transition-colors disabled:hover:bg-primary-2 disabled:opacity-50"
>
{isPending ? "저장 중..." : "저장"}
{isPending || isUploading ? "저장 중..." : "저장"}
</button>
</>
)}
Expand Down
73 changes: 48 additions & 25 deletions src/app/(main)/stories/new/StoryNewPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { useAuthStore } from "@/store/useAuthStore";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { INPUT_LIMITS } from "@/constants/inputLimits";
import { isTextOverLimit } from "@/utils/inputLimit";
import ImageAttachmentPicker from "@/components/common/ImageAttachmentPicker";
import { useImageAttachments } from "@/hooks/useImageAttachments";
import { getErrorMessage, hasErrorCode } from "@/lib/api/errors";

function StoryNewContent() {
const router = useRouter();
Expand All @@ -33,7 +36,9 @@ function StoryNewContent() {
const [title, setTitle] = useState("");
const [detail, setDetail] = useState("");
const [isBookSelectModalOpen, setIsBookSelectModalOpen] = useState(false);
const isDirty = Boolean(title.trim() || detail.trim());
const [isSubmitting, setIsSubmitting] = useState(false);
const attachments = useImageAttachments([], INPUT_LIMITS.BOOK_STORY_IMAGE_COUNT);
const isDirty = Boolean(title.trim() || detail.trim() || attachments.isDirty);
const { runWithoutGuard } = useUnsavedChangesGuard({
isDirty,
variant: "create",
Expand All @@ -42,7 +47,8 @@ function StoryNewContent() {
const { data: selectedBook } = useBookDetailQuery(isbn || "");
const createStoryMutation = useCreateBookStoryMutation();

const handleSubmit = (status: "PUBLISHED" | "DRAFT" = "PUBLISHED") => {
const handleSubmit = async (status: "PUBLISHED" | "DRAFT" = "PUBLISHED") => {
if (isSubmitting) return;
if (!selectedBook) {
toast.error("책을 선택해 주세요.");
return;
Expand Down Expand Up @@ -70,26 +76,35 @@ function StoryNewContent() {
return;
}

createStoryMutation.mutate({
isbn: selectedBook.isbn,
title,
description: detail,
status,
}, {
onSuccess: () => {
if (status === "DRAFT") {
toast.success("임시저장되었습니다.");
runWithoutGuard(() => router.push("/profile/mypage")); // 임시저장 시 마이페이지로 이동
} else {
toast.success("스토리가 등록되었습니다!");
runWithoutGuard(() => router.push("/stories"));
}
},
onError: (error) => {
console.error("스토리 등록 실패:", error);
toast.error(status === "DRAFT" ? "임시저장에 실패했습니다." : "스토리 등록에 실패했습니다. 다시 시도해 주세요.");
setIsSubmitting(true);
try {
const imageUrls = await attachments.resolveUrls("BOOK_STORY");
await createStoryMutation.mutateAsync({
isbn: selectedBook.isbn,
title,
description: detail,
imageUrls,
status,
});
if (status === "DRAFT") {
toast.success("임시저장되었습니다.");
runWithoutGuard(() => router.push("/profile/mypage"));
} else {
toast.success("스토리가 등록되었습니다!");
runWithoutGuard(() => router.push("/stories"));
}
});
} catch (error) {
console.error("스토리 등록 실패:", error);
toast.error(
hasErrorCode(error) && (error.code === "S3_400" || error.code.startsWith("BOOK_STORY_IMAGE_"))
? getErrorMessage(error.code)
: status === "DRAFT"
? "임시저장에 실패했습니다."
: "스토리 등록에 실패했습니다. 다시 시도해 주세요."
);
} finally {
setIsSubmitting(false);
}
};

const handleBookSelect = (selectedIsbn: string) => {
Expand Down Expand Up @@ -168,24 +183,32 @@ function StoryNewContent() {
/>
</div>

<div className="mx-auto mt-4 w-full max-w-[1040px]">
<ImageAttachmentPicker
controller={attachments}
disabled={isSubmitting || createStoryMutation.isPending}
label="이미지 첨부"
/>
</div>

{/* 하단 버튼 */}
<div className="flex justify-center">
<div className="flex w-full max-w-[1040px] justify-center t:justify-end gap-4 mt-6">
<button
type="button"
onClick={() => handleSubmit("DRAFT")}
disabled={createStoryMutation.isPending}
disabled={isSubmitting || createStoryMutation.isPending}
className="flex px-4 py-3 w-[132px] h-[44px] justify-center items-center rounded-lg border border-primary-1 text-primary-3 body_1_2 bg-background transition-colors hover:bg-Subbrown-3 disabled:hover:bg-background disabled:opacity-50"
>
{createStoryMutation.isPending ? "임시저장 중..." : "임시저장"}
{isSubmitting || createStoryMutation.isPending ? "임시저장 중..." : "임시저장"}
</button>
<button
type="button"
onClick={() => handleSubmit("PUBLISHED")}
disabled={createStoryMutation.isPending}
disabled={isSubmitting || createStoryMutation.isPending}
className="flex px-4 py-3 w-[132px] h-[44px] justify-center items-center rounded-lg bg-primary-2 text-White body_1_2 hover:bg-primary-1 transition-colors disabled:hover:bg-primary-2 disabled:opacity-50"
>
{createStoryMutation.isPending ? "등록 중..." : "등록"}
{isSubmitting || createStoryMutation.isPending ? "등록 중..." : "등록"}
</button>
</div>
</div>
Expand Down
Loading