Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2e70bc0
#92 fix: 서버 채팅 시간의 타임존 누락으로 인한 시차 문제 해결
JiiminHa Mar 26, 2026
1aeba79
#92 fix: 낙관적 전송 시 내 메시지 중복 표시 방지
JiiminHa Mar 26, 2026
5e31e5e
#92 chore: React Query Devtools 추가
JiiminHa Mar 26, 2026
a1f1900
#92 feat: 학생 과제 코드 미리보기 기능 추가
JiiminHa Mar 26, 2026
27f96c1
#92 style: 학생 프로필 UI 개선
JiiminHa Mar 26, 2026
85d3602
#92 fix: 레이아웃 overflow 속성 최적화
JiiminHa Mar 26, 2026
2e45964
#92 fix: 과제 코드 조회 쿼리 타입 일관성 개선
JiiminHa Mar 26, 2026
4738d55
#92 feat: 학생 프로필에서 1:1 채팅 이동 기능 추가
JiiminHa Mar 26, 2026
e371d30
#92 feat: memberId 쿼리로 채팅방 자동 선택 처리
JiiminHa Mar 26, 2026
0a2d9d2
Merge branch 'develop' into fix/92-issues
JiiminHa Mar 26, 2026
b3983e9
#92 refactor: 코드 에디터 값 접근용 ref 인터페이스 분리
JiiminHa Mar 26, 2026
c99c353
#92 feat: 채팅 패널 compact 모드와 커스텀 액션 영역 추가
JiiminHa Mar 26, 2026
b5eb98a
#92 feat: 과제 제출 페이지 질문하기 모달/버튼 및 코드 첨부 질문 흐름 추가
JiiminHa Mar 26, 2026
48bfe9d
#92 fix: 웹소켓 낙관적 업데이트 중복 제거 로직 개선
JiiminHa Mar 26, 2026
43d243c
#92 feat: 강좌 제어 접근 가드 구현
JiiminHa Mar 26, 2026
5648c1b
#92 chore: 경로 파라미터 이름 통일
JiiminHa Mar 26, 2026
3ff62b2
#92 feat: 인증 리다이렉션 개선
JiiminHa Mar 26, 2026
ef4db88
#92 feat: 파비콘 적용 및 제목
JiiminHa Mar 26, 2026
e4bc55f
#92 chore: 불필요한 css 삭제
JiiminHa Mar 26, 2026
0ea44cd
Merge pull request #96 from 2025-snowCode/fix/92-issues
JiiminHa Mar 26, 2026
acf747b
Merge branch 'main' into release/0.9.0
JiiminHa Mar 26, 2026
58a24b1
build: synchronize pnpm-lock.yaml with updated package.json
JiiminHa Mar 26, 2026
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: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>snowcode</title>
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@stomp/stompjs": "^7.3.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-query-devtools": "^5.95.2",
"axios": "^1.13.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
Expand Down
1,258 changes: 651 additions & 607 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 30 additions & 22 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import StudentManagementPage from '@/pages/admin/student/StudentManagementPage';
import StudentProfilePage from '@/pages/admin/student/StudentProfilePage';
import KakaoCallbackPage from '@/pages/common/KakaoCallbackPage';
import PrivateRoute from '@/widgets/private-route/ui/PrivateRoute';
import CourseOwnershipGuard from '@/widgets/course-ownership-guard/ui/CourseOwnershipGuard';
import {useSyncUserRole} from '@/features/auth/sync-user-role/model/useSyncUserRole';
import AssignmentManagePage from '@/pages/manage-assignment/AssignmentManagePage';
import ChatPage from '@/pages/chat/ChatPage';
Expand All @@ -36,7 +37,9 @@ const AppRoutes = () => {
<Route element={<PrivateRoute allowedRoles={['student']} />}>
<Route path='student'>
<Route index element={<Dashboard />} />
<Route path='courses/:id' element={<CourseOverviewPage />} />
<Route path='courses/:courseId' element={<CourseOwnershipGuard />}>
<Route index element={<CourseOverviewPage />} />
</Route>
<Route path='chat' element={<ChatPage />} />
</Route>
</Route>
Expand All @@ -58,20 +61,21 @@ const AppRoutes = () => {
path='assignments/select'
element={<AssignmentSelectPage />}
/>
<Route path='courses/:id' element={<CourseOverviewPage />} />
<Route path='courses/create' element={<CourseCreatePage />} />
<Route path='courses/:id/edit' element={<CourseEditPage />} />
<Route
path='courses/:courseId/students'
element={<StudentManagementPage />}
/>
<Route
path='courses/:courseId/students/:studentId'
element={<StudentProfilePage />}
/>
<Route path='units/:courseId' element={<UnitLayout />}>
<Route path='create' element={<UnitCreatePage />} />
<Route path='edit/:unitId' element={<UnitEditPage />} />
<Route path='courses/:courseId' element={<CourseOwnershipGuard />}>
<Route index element={<CourseOverviewPage />} />
<Route path='edit' element={<CourseEditPage />} />
<Route path='students' element={<StudentManagementPage />} />
<Route
path='students/:studentId'
element={<StudentProfilePage />}
/>
</Route>
<Route path='units/:courseId' element={<CourseOwnershipGuard />}>
<Route element={<UnitLayout />}>
<Route path='create' element={<UnitCreatePage />} />
<Route path='edit/:unitId' element={<UnitEditPage />} />
</Route>
</Route>
<Route path='chat' element={<ChatPage />} />
</Route>
Expand All @@ -81,16 +85,20 @@ const AppRoutes = () => {
{/* 과제 제출 페이지 */}
<Route element={<AssignmentSubmitLayout />}>
<Route element={<PrivateRoute allowedRoles={['admin']} />}>
<Route
path='admin/courses/:courseId/assignments/:assignmentId'
element={<AssignmentSubmitPage />}
/>
<Route path='admin/courses/:courseId' element={<CourseOwnershipGuard />}>
<Route
path='assignments/:assignmentId'
element={<AssignmentSubmitPage />}
/>
</Route>
</Route>
<Route element={<PrivateRoute allowedRoles={['student']} />}>
<Route
path='student/courses/:courseId/assignments/:assignmentId'
element={<AssignmentSubmitPage />}
/>
<Route path='student/courses/:courseId' element={<CourseOwnershipGuard />}>
<Route
path='assignments/:assignmentId'
element={<AssignmentSubmitPage />}
/>
</Route>
</Route>
</Route>
</Routes>
Expand Down
1 change: 0 additions & 1 deletion src/entities/assignment/api/assignmentQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,5 @@ export const assignmentQueries = {
queryOptions({
queryKey: ['code', codeId],
queryFn: () => getAssignmentCode(codeId),
select: (data) => data.code,
}),
};
72 changes: 59 additions & 13 deletions src/entities/chat/model/useChatSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
import type {TChatMessage, TSendMessage} from '@/entities/chat/model/schemas';
import {useUserStore} from '@/entities/auth/model/useUserStore';

export const useChatSocket = (chatRoomId: number | null) => {
export const useChatSocket = (
chatRoomId: number | null,
myMemberId: number
) => {
const clientRef = useRef<Client | null>(null);
const [messages, setMessages] = useState<TChatMessage[]>([]);
// 최근 내가 이 탭에서 보낸 메시지들을 추적하여 중복 방지
const sentMessagesRef = useRef<Map<string, number>>(new Map());

useEffect(() => {
if (!chatRoomId) return;
Expand All @@ -23,8 +28,18 @@
console.log('Received raw message:', rawData);
const parsed = socketMessageResponseSchema.parse(rawData);

// 현재 보고 있는 채팅방의 메시지인 경우에만 상태 업데이트
if (parsed.chatRoomId === chatRoomId) {
// 이 탭에서 방금 보낸 메시지인지 확인 (중복 방지)
if (parsed.senderId === myMemberId) {
const msgKey = parsed.content;
const count = sentMessagesRef.current.get(msgKey) ?? 0;
if (count > 0) {
if (count === 1) sentMessagesRef.current.delete(msgKey);
else sentMessagesRef.current.set(msgKey, count - 1);
return;
}
}

const message: TChatMessage = {
messageId: Date.now(),
memberId: parsed.senderId,
Expand Down Expand Up @@ -52,23 +67,54 @@
client.deactivate();
clientRef.current = null;
setMessages([]);
sentMessagesRef.current.clear();

Check warning on line 70 in src/entities/chat/model/useChatSocket.ts

View workflow job for this annotation

GitHub Actions / build-and-deploy

The ref value 'sentMessagesRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'sentMessagesRef.current' to a variable inside the effect, and use that variable in the cleanup function
};
}, [chatRoomId]);
}, [chatRoomId, myMemberId]);

const sendMessage = (payload: TSendMessage): boolean => {
const client = clientRef.current;
if (!client?.connected) {
console.warn('WebSocket 연결이 끊겨 있습니다.');

if (!client?.connected || !client?.active) {
console.warn('WebSocket이 활성화되어 있지 않거나 끊겨 있습니다.', {
connected: client?.connected,
active: client?.active,
});
return false;
}

try {
const token = useUserStore.getState().accessToken;

client.publish({
destination: '/pub/chat',
headers: {Authorization: token ? `Bearer ${token}` : ''},
body: JSON.stringify(payload),
});

// 낙관적 업데이트 (Optimistic UI)
const now = new Date();
const isoString = now.toISOString();
const newMessage: TChatMessage = {
messageId: Date.now(), // 임시 ID
memberId: myMemberId,
messageType: payload.type,
content: payload.content,
sendAt: isoString,
};

const msgKey = payload.content;
sentMessagesRef.current.set(
msgKey,
(sentMessagesRef.current.get(msgKey) ?? 0) + 1
);

setMessages((prev) => [...prev, newMessage]);

return true;
} catch (error) {
console.error('메세지 전송 중 에러 발생:', error);
return false;
}
console.log('메세지 전송중:', payload);
const token = useUserStore.getState().accessToken;
client.publish({
destination: '/pub/chat',
headers: {Authorization: token ? `Bearer ${token}` : ''},
body: JSON.stringify(payload),
});
return true;
};

return {messages, sendMessage};
Expand Down
1 change: 1 addition & 0 deletions src/entities/course/model/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const courseOverviewSchema = courseBaseSchema.extend({
studentCount: z.number().optional(),
unitCount: z.number(),
units: z.array(unitSchema),
chatRoomId: z.number().nullable().optional(),
});

/** 대시보드 강의 카드용 스키마 */
Expand Down
90 changes: 61 additions & 29 deletions src/entities/student/ui/AssignmentProgressCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, {useState} from 'react';
import type {StudentDetail} from '@/entities/student/model/types';
import {ProgressIndicators} from '@/shared/ui/ProgressIndicators';
import Correct from '@/assets/svg/correct.svg?react';
import Unsubmitted from '@/assets/svg/unsubmitted.svg?react';
import CodePreview from '@/features/student/ui/CodePreview';

interface AssignmentProgressCardProps {
student: StudentDetail;
Expand All @@ -10,21 +12,37 @@ interface AssignmentProgressCardProps {
export const AssignmentProgressCard = ({
student,
}: AssignmentProgressCardProps) => {
const [expandedCodeIds, setExpandedCodeIds] = useState<Set<number>>(
new Set()
);

const getStatusIcon = (isCorrect: boolean) => {
if (isCorrect) {
return (
<div className='w-[31px] h-[31px] rounded-full border border-primary flex items-center justify-center'>
<div className='w-7.75 h-7.75 rounded-full border border-primary flex items-center justify-center'>
<Correct className='w-3 h-3' />
</div>
);
}
return (
<div className='w-[31px] h-[31px] rounded-full border border-light-black flex items-center justify-center'>
<div className='w-7.75 h-7.75 rounded-full border border-light-black flex items-center justify-center'>
<Unsubmitted className='w-3 h-3' />
</div>
);
};

const toggleExpandCode = (codeId: number) => {
setExpandedCodeIds((prev) => {
const next = new Set(prev);
if (next.has(codeId)) {
next.delete(codeId);
} else {
next.add(codeId);
}
return next;
});
};

return (
<div className='p-8 flex flex-col gap-6'>
<div className='flex gap-10'>
Expand All @@ -40,11 +58,11 @@ export const AssignmentProgressCard = ({
</div>

{student.units.map((unit, unitIndex) => (
<table key={unit.id} className='w-full'>
<table key={unit.id} className='w-full border-collapse'>
<thead>
<tr className='text-sm text-secondary-black bg-gray'>
<th className='text-left font-medium py-4.5 pl-4' colSpan={3}>
<div className='flex items-center gap-[13px]'>
<div className='flex items-center gap-3.25'>
<span className='px-3.5 py-1.5 rounded-full bg-secondary-black text-white text-md font-medium'>
{unitIndex + 1}
</span>
Expand All @@ -62,31 +80,45 @@ export const AssignmentProgressCard = ({
</thead>
<tbody>
{unit.assignments.map((assignment, index) => (
<tr
key={assignment.id}
className='border-b border-purple-stroke last:border-b-0'>
<td className='px-4 w-8 py-4.5 text-center text-[16px] text-light-black font-medium'>
<div className='rounded-full border border-purple-stroke w-[31px] h-[31px] flex items-center justify-center'>
{index + 1}
</div>
</td>
<td className='pl-4.5 py-4.5 text-base text-secondary-black'>
{assignment.title}
</td>
<td className='w-19 py-4.5 text-center items-center justify-center'>
<div className='flex items-center justify-center'>
{getStatusIcon(assignment.isCorrect)}
</div>
</td>
<td className='w-25 py-4.5 text-center text-sm text-secondary-black'>
{assignment.score}/{assignment.totalScore}
</td>
<td className='py-4.5 text-center'>
<button className='px-4 py-1.5 text-sm text-primary cursor-pointer'>
보기
</button>
</td>
</tr>
<React.Fragment key={assignment.id}>
<tr className='border-b border-purple-stroke last:border-b-0'>
<td className='px-4 w-8 py-4.5 text-center text-[16px] text-light-black font-medium'>
<div className='rounded-full border border-purple-stroke w-7.75 h-7.75 flex items-center justify-center'>
{index + 1}
</div>
</td>
<td className='pl-4.5 py-4.5 text-base text-secondary-black'>
{assignment.title}
</td>
<td className='w-19 py-4.5 text-center items-center justify-center'>
<div className='flex items-center justify-center'>
{getStatusIcon(assignment.isCorrect)}
</div>
</td>
<td className='w-25 py-4.5 text-center text-sm text-secondary-black'>
{assignment.score}/{assignment.totalScore}
</td>
<td className='py-4.5 text-center'>
<button
className='px-4 py-1.5 text-sm text-primary cursor-pointer hover:underline disabled:opacity-30 disabled:cursor-not-allowed'
disabled={!assignment.submittedCodeId}
onClick={() =>
toggleExpandCode(assignment.submittedCodeId)
}>
{expandedCodeIds.has(assignment.submittedCodeId)
? '닫기'
: '보기'}
</button>
</td>
</tr>
{expandedCodeIds.has(assignment.submittedCodeId) && (
<tr>
<td colSpan={5} className='px-4 pb-4 bg-gray/10'>
<CodePreview codeId={assignment.submittedCodeId} />
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
Expand Down
Loading
Loading