diff --git a/app/routes/rooms/api/attachments.ts b/app/routes/rooms/api/attachments.ts index 66d4288a..e8ae16da 100644 --- a/app/routes/rooms/api/attachments.ts +++ b/app/routes/rooms/api/attachments.ts @@ -1,14 +1,51 @@ -import { axiosInstance } from "../../../api/axios"; +export type AttachmentType = "IMAGE" | "FILE"; +export type AttachmentUsage = "CHAT" | "PUBLIC"; -export async function uploadAttachment(file: File) { - const formData = new FormData(); - formData.append("file", file); +export interface ChatAttachmentUploadResponse { + attachmentId: number; + attachmentType: "IMAGE" | "FILE"; + contentType: string; + originalName: string; + fileSize: number; + accessUrl: string; // presigned (TTL) + status: "READY"; + createdAt: string; +} - const res = await axiosInstance.post("/api/v1/attachments", formData, { +/** + * 주의: + * - fetch를 쓸 때 Content-Type을 직접 multipart/form-data로 세팅하지 마세요. + * 브라우저가 boundary 포함해서 자동으로 잡아줘야 합니다. + */ +export async function uploadAttachment(params: { + token: string; + file: File; + attachmentType: AttachmentType; + usage: AttachmentUsage; // 보통 "CHAT" + baseUrl: string; // 예: import.meta.env.VITE_API_BASE_URL +}): Promise { + const { token, file, attachmentType, usage, baseUrl } = params; + + const form = new FormData(); + form.append("attachmentType", attachmentType); + form.append("usage", usage); + form.append("file", file, file.name); + + const res = await fetch(`${baseUrl}/api/v1/attachments`, { + method: "POST", headers: { - "Content-Type": "multipart/form-data", + Authorization: `Bearer ${token}`, + // Content-Type 넣지 말기! }, + body: form, }); - return res.data; + // 실패시 throw + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Attachment upload failed: ${res.status} ${text}`); + } + + const data = (await res.json()) as ChatAttachmentUploadResponse; + return data; } \ No newline at end of file diff --git a/app/routes/rooms/chatting-room.tsx b/app/routes/rooms/chatting-room.tsx index 9aefb6de..56ef2ff7 100644 --- a/app/routes/rooms/chatting-room.tsx +++ b/app/routes/rooms/chatting-room.tsx @@ -1,25 +1,25 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { tokenStorage } from "../../lib/token"; import NavigationHeader from "../../components/common/NavigateHeader"; import ChatComposer from "./components/ChatComposer"; -import AttachmentSheet, { type AttachmentAction } from "./components/AttachmentSheet"; +import AttachmentSheet, { + type AttachmentAction, +} from "./components/AttachmentSheet"; import useKeyboardOffset from "../../hooks/KeyboardOffset"; import MessageRenderer from "./components/MessageRender"; import { formatKoreanDateTime } from "../../utils/dateTime"; import CollaborationSummaryBar from "./components/CollaborationBar"; import { useHideBottomTab } from "../../hooks/useHideBottomTab"; import { useHideHeader } from "../../hooks/useHideHeader"; - import { - createOrGetDirectRoom, getChatRoomDetail, type ChatRoomDetailResponse, getChatMessages, type ChatMessage, + createOrGetDirectRoom, } from "./api/rooms"; - -import { useAuthStore } from "../../stores/auth-store"; +import useAttachmentUpload from "../rooms/hooks/useAttachmentUpload"; +import { tokenStorage } from "../../lib/token"; type Props = { brandId: number; @@ -38,19 +38,21 @@ export default function ChattingRoom({ brandId }: Props) { const inputRef = useRef(null); const listRef = useRef(null); + const myUserId = Number(tokenStorage.getUserId() ?? 0); + const accessToken = tokenStorage.getAccessToken(); + const baseUrl = import.meta.env.VITE_API_BASE_URL; + useHideBottomTab(true); useHideHeader(true); - const accessToken = tokenStorage.getAccessToken?.(); - const myUserId = useAuthStore((s) => Number(s.me?.id ?? 0)); - const partnerName = detail?.opponentName ?? ""; const partnerAvatarUrl = detail?.opponentProfileImageUrl ?? ""; const isCollaborating = detail?.isCollaborating ?? false; const collabTitle = detail?.campaignSummary?.campaignTitle ?? ""; const collabSubtitle = detail?.campaignSummary?.brandName ?? ""; - const collabThumb = detail?.campaignSummary?.campaignImageUrl ?? partnerAvatarUrl; + const collabThumb = + detail?.campaignSummary?.campaignImageUrl ?? partnerAvatarUrl; const summaryBarHeight = isCollaborating ? 64 : 0; const createdAt = useMemo(() => { @@ -72,7 +74,10 @@ export default function ChattingRoom({ brandId }: Props) { const run = async () => { try { - const result = await createOrGetDirectRoom({ brandId, creatorId: myUserId }); + const result = await createOrGetDirectRoom({ + brandId, + creatorId: myUserId, + }); setRoomId(result.roomId); } catch (e) { console.error("createOrGetDirectRoom failed:", e); @@ -83,6 +88,26 @@ export default function ChattingRoom({ brandId }: Props) { run(); }, [accessToken, brandId, myUserId]); + const imageInputRef = useRef(null); + const fileInputRef = useRef(null); + + const { upload } = useAttachmentUpload({ + baseUrl, + accessToken, + defaultUsage: "CHAT", + }); + + const handleAttachmentAction = (key: "suggest" | "image" | "file") => { + if (key === "image") { + imageInputRef.current?.click(); + return; + } + if (key === "file") { + fileInputRef.current?.click(); + return; + } + }; + useEffect(() => { if (!accessToken) return; if (!roomId) return; @@ -169,6 +194,78 @@ export default function ChattingRoom({ brandId }: Props) { requestAnimationFrame(() => inputRef.current?.focus()); }; + const handlePickImage = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + + try { + const uploaded = await upload({ file, attachmentType: "IMAGE" }); + + const tempMessage: ChatMessage = { + messageId: -Date.now(), + roomId, + senderId: myUserId, + senderType: "USER", + messageType: "IMAGE", + content: null, + attachment: { + attachmentId: uploaded.attachmentId, + attachmentType: "IMAGE", + contentType: uploaded.contentType, + originalName: uploaded.originalName, + fileSize: uploaded.fileSize, + accessUrl: uploaded.accessUrl, + status: "READY", + }, + systemMessage: null, + createdAt: new Date().toISOString(), + clientMessageId: crypto.randomUUID(), + }; + + setMessages((prev) => [...prev, tempMessage]); + setIsSheetOpen(false); + } catch (err) { + console.error(err); + } + }; + + const handlePickFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + + try { + const uploaded = await upload({ file, attachmentType: "FILE" }); + + const tempMessage: ChatMessage = { + messageId: -Date.now(), + roomId, + senderId: myUserId, + senderType: "USER", + messageType: "FILE", + content: null, + attachment: { + attachmentId: uploaded.attachmentId, + attachmentType: "FILE", + contentType: uploaded.contentType, + originalName: uploaded.originalName, + fileSize: uploaded.fileSize, + accessUrl: uploaded.accessUrl, + status: "READY", + }, + systemMessage: null, + createdAt: new Date().toISOString(), + clientMessageId: crypto.randomUUID(), + }; + + setMessages((prev) => [...prev, tempMessage]); + setIsSheetOpen(false); + } catch (err) { + console.error(err); + } + }; + if (!accessToken) { return (
@@ -198,10 +295,29 @@ export default function ChattingRoom({ brandId }: Props) { return (
+ + + + history.back()} /> {detail?.campaignSummary && ( - + )}
); })} @@ -243,6 +358,7 @@ export default function ChattingRoom({ brandId }: Props) { actions={actions} onClose={handleCloseSheet} onAction={(key) => { + handleAttachmentAction(key); console.log("action:", key); setIsSheetOpen(false); }} diff --git a/app/routes/rooms/components/MessageRender.tsx b/app/routes/rooms/components/MessageRender.tsx index be0d5109..42816ce6 100644 --- a/app/routes/rooms/components/MessageRender.tsx +++ b/app/routes/rooms/components/MessageRender.tsx @@ -10,14 +10,12 @@ type Props = { message: ChatMessage; timeText?: string; avatarSrc?: string; - isCollaborating?: boolean; }; export default function MessageRenderer({ message, timeText, avatarSrc, - //isCollaborating, }: Props) { switch (message.messageType) { diff --git a/app/routes/rooms/hooks/useAttachmentUpload.ts b/app/routes/rooms/hooks/useAttachmentUpload.ts new file mode 100644 index 00000000..d873d11e --- /dev/null +++ b/app/routes/rooms/hooks/useAttachmentUpload.ts @@ -0,0 +1,76 @@ +import { useCallback, useState } from "react"; +import { uploadAttachment, type ChatAttachmentUploadResponse, type AttachmentType, type AttachmentUsage } from "../api/attachments"; + +/** + * 업로드 훅 + * - uploading / error / lastUploaded 상태 제공 + * - upload(file, options) 호출하면 attachment 업로드 수행 + */ +export default function useAttachmentUpload(params: { + baseUrl: string; + token: string | null; + defaultUsage?: AttachmentUsage; // 기본 "CHAT" +}) { + const { baseUrl, token, defaultUsage = "CHAT" } = params; + + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [lastUploaded, setLastUploaded] = + useState(null); + + const upload = useCallback( + async (args: { + file: File; + attachmentType?: AttachmentType; // 미지정 시 자동 판별 + usage?: AttachmentUsage; + }) => { + if (!token) { + throw new Error("로그인이 필요합니다. (token이 없습니다)"); + } + + const { file } = args; + const usage = args.usage ?? defaultUsage; + + const attachmentType: AttachmentType = + args.attachmentType ?? + (file.type.startsWith("image/") ? "IMAGE" : "FILE"); + + setUploading(true); + setError(null); + + try { + const uploaded = await uploadAttachment({ + token, + file, + attachmentType, + usage, + baseUrl, + }); + + setLastUploaded(uploaded); + return uploaded; + } catch (e) { + const msg = e instanceof Error ? e.message : "업로드 실패"; + setError(msg); + throw e; + } finally { + setUploading(false); + } + }, + [token, baseUrl, defaultUsage] + ); + + const reset = useCallback(() => { + setError(null); + setLastUploaded(null); + setUploading(false); + }, []); + + return { + uploading, + error, + lastUploaded, + upload, + reset, + }; +} \ No newline at end of file diff --git a/app/routes/rooms/hooks/useAuthScroll.ts b/app/routes/rooms/hooks/useAuthScroll.ts new file mode 100644 index 00000000..6bb15971 --- /dev/null +++ b/app/routes/rooms/hooks/useAuthScroll.ts @@ -0,0 +1,12 @@ +import { useEffect } from "react"; + +export function useAutoScroll( + listRef: React.RefObject, + deps: React.DependencyList +) { + useEffect(() => { + const el = listRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, deps); +} diff --git a/app/routes/rooms/hooks/useChatRoom.ts b/app/routes/rooms/hooks/useChatRoom.ts new file mode 100644 index 00000000..1ac8a2c1 --- /dev/null +++ b/app/routes/rooms/hooks/useChatRoom.ts @@ -0,0 +1,90 @@ +import { useEffect, useMemo, useState } from "react"; +import { useAuthStore } from "../../../stores/auth-store"; +import { getChatMessages, getChatRoomDetail, type ChatMessage, type ChatRoomDetailResponse, } from "../api/rooms"; + +type UseChatRoomArgs = { + roomId: number; + pageSize?: number; +}; + +export function useChatRoom({ roomId, pageSize = 20 }: UseChatRoomArgs) { + const [detail, setDetail] = useState(null); + const [messages, setMessages] = useState([]); + const [isLoadingDetail, setIsLoadingDetail] = useState(false); + const [isLoadingMessages, setIsLoadingMessages] = useState(false); + + const myUserId = useAuthStore((s) => Number(s.me?.id ?? 0)); + + useEffect(() => { + if (!Number.isFinite(roomId)) return; + + const run = async () => { + setIsLoadingDetail(true); + try { + const data = await getChatRoomDetail(roomId); + setDetail(data); + } catch (e) { + console.error("getChatRoomDetail failed:", e); + setDetail(null); + } finally { + setIsLoadingDetail(false); + } + }; + + run(); + }, [roomId]); + + useEffect(() => { + if (!Number.isFinite(roomId)) return; + + const run = async () => { + setIsLoadingMessages(true); + try { + const data = await getChatMessages({ roomId, size: pageSize }); + // 오래된 -> 최신 순 + setMessages(data.messages.slice().reverse()); + } catch (e) { + console.error("getChatMessages failed:", e); + setMessages([]); + } finally { + setIsLoadingMessages(false); + } + }; + + run(); + }, [roomId, pageSize]); + + const derived = useMemo(() => { + const partnerName = detail?.opponentName ?? ""; + const partnerAvatarUrl = detail?.opponentProfileImageUrl ?? ""; + const isCollaborating = detail?.isCollaborating ?? false; + + const collabTitle = detail?.campaignSummary?.campaignTitle ?? ""; + const collabSubtitle = detail?.campaignSummary?.brandName ?? ""; + const collabThumb = + detail?.campaignSummary?.campaignImageUrl ?? partnerAvatarUrl; + + const summaryBarHeight = isCollaborating ? 64 : 0; + + return { + partnerName, + partnerAvatarUrl, + isCollaborating, + collabTitle, + collabSubtitle, + collabThumb, + summaryBarHeight, + }; + }, [detail]); + + return { + myUserId, + detail, + setDetail, + messages, + setMessages, + isLoadingDetail, + isLoadingMessages, + ...derived, + }; +} \ No newline at end of file