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
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,10 @@ function App() {
<Route path="clubs">
<Route index element={<ClubList />} />
<Route path="search" element={<ClubSearch />} />
<Route path=":clubId" element={<ClubDetail />} />
</Route>
</Route>
<Route element={<Layout />}>
<Route path="clubs/:clubId" element={<ClubDetail />} />
<Route path="clubs/:clubId/applications" element={<ApplicationPage />} />
Comment on lines 103 to 105
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

ClubDetail는 전용 Layout 배경을 분리해주세요.

ClubDetailsrc/pages/Club/ClubDetail/index.tsx에서 루트 배경을 bg-indigo-5로 두고 있지만, 실제 페이지 배경과 헤더 padding 영역은 Layout<main>이 담당합니다. 지금처럼 공용 <Layout /> 아래에 두면 상세 상단/overscroll 구간은 기본 배경색이 남아서 리디자인과 다른 띠가 보일 수 있습니다. clubs/:clubIdcontentClassName="bg-indigo-5"를 주는 Layout으로 분리하는 편이 안전합니다.

💡 제안
-            <Route element={<Layout />}>
-              <Route path="clubs/:clubId" element={<ClubDetail />} />
+            <Route element={<Layout contentClassName="bg-indigo-5" />}>
+              <Route path="clubs/:clubId" element={<ClubDetail />} />
+            </Route>
+            <Route element={<Layout />}>
               <Route path="clubs/:clubId/applications" element={<ApplicationPage />} />

As per coding guidelines src/pages/**/*.tsx: Pass showBottomNav (bottom tab display) and contentClassName (background color, etc.) props to Layout component.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/App.tsx` around lines 103 - 105, Update the routing so the clubs/:clubId
route uses a dedicated Layout instance with the page-specific props instead of
the shared Layout: change the Route for path "clubs/:clubId" to render <Layout
contentClassName="bg-indigo-5" showBottomNav={false}> wrapping <ClubDetail />
(or otherwise pass those props into Layout when rendering ClubDetail), leaving
"clubs/:clubId/applications" to use the standard Layout with its existing props;
ensure you reference the Layout component and the ClubDetail element so the
top/overscroll background and header padding come from the Layout's main area
and match the redesign.

<Route path="clubs/:clubId/fee" element={<ClubFeePage />} />
<Route path="clubs/:clubId/complete" element={<ApplyCompletePage />} />
Expand Down
6 changes: 6 additions & 0 deletions src/assets/svg/chat-send-arrow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 8 additions & 16 deletions src/components/layout/Header/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,16 @@ function ChatHeader() {

return (
<>
<header className="fixed top-0 right-0 left-0 z-30 flex h-11 items-center justify-center bg-white px-4 py-2">
<button
type="button"
aria-label="뒤로가기"
onClick={smartBack}
className="absolute top-1/2 left-4 -translate-y-1/2"
>
<ChevronLeftIcon />
</button>
<header className="fixed top-0 right-0 left-0 z-30 flex h-11 items-center bg-white px-4 py-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<button type="button" aria-label="뒤로가기" onClick={smartBack} className="shrink-0">
<ChevronLeftIcon />
</button>

<span className="text-lg">{chatRoom?.roomName ?? ''}</span>
<span className="truncate text-[18px] leading-5 font-normal text-indigo-700">{chatRoom?.roomName ?? ''}</span>
</div>

<button
type="button"
aria-label="채팅방 정보 열기"
onClick={openSidebar}
className="absolute top-1/2 right-4 -translate-y-1/2"
>
<button type="button" aria-label="채팅방 정보 열기" onClick={openSidebar} className="ml-3 shrink-0">
<HamburgerIcon />
</button>
</header>
Expand Down
4 changes: 4 additions & 0 deletions src/components/layout/Header/headerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const HEADER_CONFIGS: HeaderConfig[] = [
type: 'none',
match: (pathname) => pathname === '/',
},
{
type: 'default',
match: (pathname) => /^\/clubs\/\d+$/.test(pathname),
},
{
type: 'profile',
match: (pathname) => pathname === '/mypage',
Expand Down
4 changes: 0 additions & 4 deletions src/components/layout/Header/routeTitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,6 @@ export const ROUTE_TITLES: RouteTitle[] = [
match: (pathname) => pathname.startsWith('/clubs/search'),
title: '동아리 검색',
},
{
match: (pathname) => pathname === '/chats',
title: '채팅방',
},
{
match: (pathname) => pathname === '/council',
title: '총동아리연합회',
Expand Down
184 changes: 99 additions & 85 deletions src/pages/Chat/ChatRoom.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { useParams } from 'react-router-dom';
import PaperPlaneIcon from '@/assets/svg/paper-plane.svg';
import type { ChatMessage } from '@/apis/chat/entity';
import SendArrowIcon from '@/assets/svg/chat-send-arrow.svg';
import LinkifiedText from '@/components/common/LinkifiedText';
import useKeyboardHeight from '@/utils/hooks/useViewportHeight';
import { cn } from '@/utils/ts/cn';
import useChat from './hooks/useChat';
import useChatRoomScroll from './hooks/useChatRoomScroll';

Expand All @@ -23,6 +24,58 @@ const formatTime = (dateString: string) => {
return `${hour}:${minute}`;
};

interface ChatMessageRowProps {
isGroup: boolean;
isSameSender: boolean;
message: ChatMessage;
}

function ChatMessageRow({ isGroup, isSameSender, message }: ChatMessageRowProps) {
const showSenderName = isGroup && !message.isMine && !isSameSender;
const formattedTime = formatTime(message.createdAt);
const formattedUnreadCount = message.unreadCount > 0 ? String(message.unreadCount) : null;

if (message.isMine) {
return (
<div className="flex justify-end px-6 py-2">
<div className="flex max-w-full items-end gap-2">
{formattedUnreadCount && (
<span className="text-primary-500 text-[10px] leading-[1.6] font-medium">{formattedUnreadCount}</span>
)}
<span className="text-[10px] leading-[1.6] font-medium text-indigo-100">{formattedTime}</span>

<div className="bg-primary-500/80 text-sub4 max-w-[78%] rounded-2xl px-3 py-2 text-white shadow-[0_0_3px_rgba(0,0,0,0.15)]">
<LinkifiedText
text={message.content}
className="wrap-anywhere whitespace-pre-wrap"
linkClassName="text-white underline"
/>
</div>
</div>
</div>
);
}

return (
<div className="px-6 py-2">
<div className="max-w-full">
{showSenderName && <div className="text-sub4 text-text-400 mb-1 px-3">{message.senderName}</div>}

<div className="flex items-end gap-2">
<div className="bg-indigo-5 text-sub4 max-w-[78%] rounded-2xl px-3 py-2 text-black">
<LinkifiedText
text={message.content}
className="wrap-anywhere whitespace-pre-wrap"
linkClassName="text-primary-500 underline"
/>
</div>
<span className="shrink-0 text-[10px] leading-[1.6] font-medium text-indigo-100">{formattedTime}</span>
</div>
</div>
</div>
);
}

function ChatRoom() {
const { chatRoomId } = useParams();
const { sendMessage, chatMessages, fetchNextPage, hasNextPage, isFetchingNextPage, chatRoomList, isSendingMessage } =
Expand All @@ -47,6 +100,16 @@ function ChatRoom() {
const isGroup = currentRoom?.chatType === 'GROUP';

const sortedMessages = [...chatMessages].reverse();
const isSubmitDisabled = isSendingMessage || !value.trim();

const resetTextareaHeight = () => {
if (!textareaRef.current) return;

textareaRef.current.style.height = 'auto';
const baseHeight = baseTextareaHeightRef.current || textareaRef.current.scrollHeight;
baseTextareaHeightRef.current = baseHeight;
textareaRef.current.style.height = `${baseHeight}px`;
};

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -61,8 +124,6 @@ function ChatRoom() {

setValue('');
if (textareaRef.current) {
const baseHeight = baseTextareaHeightRef.current || textareaRef.current.scrollHeight;
textareaRef.current.style.height = `${baseHeight}px`;
textareaRef.current.focus();
}
scrollToBottom();
Expand All @@ -81,18 +142,20 @@ function ChatRoom() {
};

useEffect(() => {
if (!textareaRef.current) return;

textareaRef.current.style.height = 'auto';
baseTextareaHeightRef.current = textareaRef.current.scrollHeight;
textareaRef.current.style.height = `${baseTextareaHeightRef.current}px`;
resetTextareaHeight();
}, []);

useEffect(() => {
if (!value) {
resetTextareaHeight();
}
}, [value]);

return (
<div className="bg-indigo-0 flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col bg-white">
<div
ref={scrollContainerRef}
className="bg-indigo-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pb-4"
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain py-3"
>
Comment on lines 156 to 159
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

메시지 목록을 live region으로 노출해주세요.

현재 컨테이너는 동적으로 추가되는 메시지를 보조기기가 채팅 로그로 인식할 근거가 없습니다. 채팅처럼 순차적으로 항목이 추가되는 영역은 role="log"와 접근 가능한 이름을 두는 패턴이어서, 지금처럼 포커스가 입력창에 머무는 화면에서는 새 메시지 안내가 누락될 수 있습니다. (developer.mozilla.org)

💡 제안
       <div
         ref={scrollContainerRef}
+        role="log"
+        aria-label="채팅 메시지"
         className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain py-3"
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
ref={scrollContainerRef}
className="bg-indigo-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pb-4"
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain py-3"
>
<div
ref={scrollContainerRef}
role="log"
aria-label="채팅 메시지"
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain py-3"
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/ChatRoom.tsx` around lines 156 - 159, The message list
container (the div with ref scrollContainerRef in ChatRoom.tsx) needs to be
exposed as a live region so assistive tech announces new messages; update that
div to include role="log" and provide an accessible name (using aria-label or
aria-labelledby, e.g., aria-label="Chat messages") and consider adding
aria-atomic/aria-live attributes as needed to ensure new message announcements;
ensure the change is applied to the same element that receives appended messages
so screen readers detect additions.

<div ref={topRef} />

Expand All @@ -106,91 +169,42 @@ function ChatRoom() {
const isSameSender = prevMessage?.senderId === message.senderId && !showDateHeader;

return (
<div
key={message.messageId}
className={clsx('w-full min-w-0 px-6', showDateHeader ? 'mt-4' : isSameSender ? 'mt-1' : 'mt-3')}
>
<div key={message.messageId} className="w-full min-w-0">
{showDateHeader && (
<div className="flex justify-center py-3">
<span className="bg-indigo-25 text-primary rounded-full px-3 py-1 text-xs">
<div className={cn('flex justify-center px-6', index === 0 ? 'pb-2' : 'pt-4 pb-2')}>
<span className="text-text-400 text-sub4 rounded-2xl bg-white px-3 py-1">
{formatDate(message.createdAt)}
</span>
</div>
)}

<div className={clsx('flex w-full min-w-0 items-end', message.isMine ? 'justify-end' : 'justify-start')}>
{!message.isMine && (
<div className="flex max-w-full min-w-0 flex-col">
{isGroup && !isSameSender && (
<div className="text-body3 mb-1 pl-10 text-indigo-400">{message.senderName}</div>
)}

<div className="flex min-w-0 items-start gap-2">
{isGroup && (
<div className="w-8 shrink-0">
{!isSameSender ? (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-300 text-xs">
{message.senderName?.[0]}
</div>
) : (
<div className="h-8 w-8" />
)}
</div>
)}

<div className="bg-info-100 text-body1 rounded-lg px-3 py-2 wrap-anywhere whitespace-pre-wrap">
<LinkifiedText text={message.content} />
</div>

<div className="flex shrink-0 flex-col items-start gap-0.5 self-end">
{message.unreadCount > 0 && (
<span className="text-cap2 text-info-600 font-medium">{message.unreadCount}</span>
)}
<span className="text-cap2 text-indigo-300">{formatTime(message.createdAt)}</span>
</div>
</div>
</div>
)}

{/* ===== RIGHT (내 메시지) ===== */}
{message.isMine && (
<div className="flex max-w-full min-w-0 flex-row-reverse items-end gap-2">
<div className="bg-indigo-5 text-body1 rounded-lg px-3 py-2 wrap-anywhere whitespace-pre-wrap">
<LinkifiedText text={message.content} />
</div>

<div className="flex shrink-0 flex-col items-end self-end">
{message.unreadCount > 0 && (
<span className="text-cap2 text-info-600 font-medium">{message.unreadCount}</span>
)}
<span className="text-cap2 text-indigo-300">{formatTime(message.createdAt)}</span>
</div>
</div>
)}
</div>
<ChatMessageRow isGroup={isGroup} isSameSender={isSameSender} message={message} />
</div>
);
})}
</div>

<form onSubmit={handleSubmit} className="bg-indigo-25 flex min-w-0 shrink-0 items-end gap-2 px-5 py-2">
<textarea
ref={textareaRef}
value={value}
onChange={handleInputChange}
className="bg-indigo-0 max-h-32 min-w-0 flex-1 resize-none overflow-x-hidden rounded-sm px-3 py-2 text-sm wrap-anywhere whitespace-pre-wrap text-indigo-700 placeholder:text-indigo-500"
rows={1}
placeholder="메세지 보내기"
maxLength={1000}
/>

<button
type="submit"
disabled={isSendingMessage || !value.trim()}
className="bg-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-sm disabled:opacity-50"
>
<PaperPlaneIcon className="text-indigo-0" />
</button>
<form onSubmit={handleSubmit} className="shrink-0 bg-white px-5 pt-3 pb-[calc(22px+var(--sab))]">
<div className="bg-text-100 flex min-w-0 items-end gap-3 rounded-[30px] px-4 py-3">
<textarea
ref={textareaRef}
value={value}
onChange={handleInputChange}
aria-label="메시지 입력"
className="text-text-600 text-sub4 placeholder:text-text-300 max-h-32 min-h-6 min-w-0 flex-1 resize-none overflow-x-hidden bg-transparent py-1 wrap-anywhere whitespace-pre-wrap outline-none"
rows={1}
maxLength={1000}
/>

<button
type="submit"
aria-label="메시지 전송"
disabled={isSubmitDisabled}
className="text-text-600 disabled:text-text-300 flex size-6 shrink-0 items-center justify-center"
>
<SendArrowIcon className="size-6" />
</button>
</div>
</form>
</div>
);
Expand Down
Loading
Loading