Skip to content
30 changes: 28 additions & 2 deletions src/components/common/system/header/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { useNavigate } from "react-router-dom";
import ArrowLeft from "@/assets/icons/arrow_left.svg";
import MeetBall from "@/assets/icons/meetball.svg";
import Refresh from "@/assets/icons/refresh.svg";
import clsx from "clsx";

interface PageHeaderProps {
title: string;
onBackClick?: () => void;
onMoreClick?: () => void;
onRefreshClick?: () => void;
/** true면 새로고침 아이콘이 회전한다. */
refreshing?: boolean;
className?: string;
}

export const PageHeader = ({
title,
onBackClick,
onMoreClick,
onRefreshClick,
refreshing = false,
className,
}: PageHeaderProps) => {
const navigate = useNavigate();
Expand All @@ -31,7 +37,7 @@ export const PageHeader = ({
className,
)}
>
<button type="button" className="p-1">
<button type="button" className="p-1 cursor-pointer">
<img
src={ArrowLeft}
className="w-6"
Expand All @@ -42,8 +48,28 @@ export const PageHeader = ({

<div className="flex justify-start flex-1 header-h4">{title}</div>

{onRefreshClick && (
<button
type="button"
className="p-1 cursor-pointer disabled:cursor-default"
onClick={onRefreshClick}
disabled={refreshing}
aria-label="새로고침"
>
<img
src={Refresh}
className={clsx("w-6", refreshing && "animate-spin")}
alt="새로고침"
/>
</button>
)}

{onMoreClick && (
<button type="button" className="p-1" onClick={onMoreClick}>
<button
type="button"
className="p-1 cursor-pointer"
onClick={onMoreClick}
>
<img src={MeetBall} className="w-6" alt="meetball icon" />
</button>
)}
Expand Down
7 changes: 7 additions & 0 deletions src/components/home/WorkoutDayEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export const WorkoutDayEntry = ({

return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1">
<span className="flex-1 h-px bg-ye-300 rounded-full" />
<span className="body-sm-400 text-gr-700 whitespace-nowrap">
클릭해서 운동 상세·게임판 보기
</span>
<span className="flex-1 h-px bg-ye-300 rounded-full" />
</div>
{exerciseData.map(item => (
<DailyExercise_S
key={item.exerciseId}
Expand Down
15 changes: 15 additions & 0 deletions src/layout/GroupLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import axios, { AxiosError } from "axios";
import type { CommonResponse } from "../types/common";
import type { PersonalChatRoom } from "../types/chat";
import { Modal_Del } from "../components/group/Modal_Del";
import { useRoomIdByPartyId } from "../api/chat/getRoomIdByPartyId";
import { useChatWsStore } from "../store/useChatWsStore";

const options = [
{ label: "홈", value: "" },
Expand Down Expand Up @@ -156,6 +158,18 @@ export const GroupLayout = () => {
partyDetail?.memberRole === "PARTY_MANAGER" ||
partyDetail?.memberRole === "PARTY_SUBMANAGER";

// 모임 채팅 탭 안읽음 표시: 가입 회원에게만, 새 메시지가 있으면 빨간 점.
// 채팅 탭 진입 시 GroupChatDetailTemplate이 clearUnread로 0을 만들어 자동으로 사라진다.
const { data: groupChatRoomId } = useRoomIdByPartyId(
Number(groupId),
!!groupId && isJoined,
);
const hasGroupChatUnread = useChatWsStore(s =>
groupChatRoomId
? (s.meta[groupChatRoomId]?.unreadCount ?? 0) > 0
: false,
);

return (
<div className="flex flex-col">
<PageHeader
Expand All @@ -169,6 +183,7 @@ export const GroupLayout = () => {
selected={select}
onChange={handleChange}
type="group"
dots={{ chat: hasGroupChatUnread }}
/>

<div className="pt-14">
Expand Down
10 changes: 7 additions & 3 deletions src/pages/group/GroupCalendarPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ export const GroupCalendarPage = () => {

<div className="flex flex-col pb-15">
{selectedDayExercises.length > 0 ? (
selectedDayExercises.map(exercise => (
selectedDayExercises.map((exercise, index) => (
<div
className="border-b-1 border-gy-200 mb-3"
className={`mb-3 ${
index < selectedDayExercises.length - 1
? "border-b-1 border-gy-200"
: ""
}`}
key={exercise.exerciseId}
>
<ContentCardL
Expand All @@ -94,7 +98,7 @@ export const GroupCalendarPage = () => {
)}
</div>

{calendarData?.isMember && (
{calendarData?.isMember && selectedDayExercises.length > 0 && (
<div className="fixed bottom-0">
<Grad_GR400_L label="이 날 운동 공유하기" onClick={onClickShare} />
</div>
Expand Down
99 changes: 91 additions & 8 deletions src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import {
DndContext,
Expand Down Expand Up @@ -67,9 +67,18 @@ const MAX_SELECTED_MEMBERS = 4;
interface GameBoardTabProps {
gameBoardId: number;
isManager: boolean;
/** 헤더 새로고침 버튼을 누를 때마다 증가. 값이 바뀌면 보드/명단을 다시 불러온다. */
refreshSignal?: number;
/** 새로고침 요청이 진행 중인지 상위(헤더)로 알린다. */
onRefreshingChange?: (refreshing: boolean) => void;
}

export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
export const GameBoardTab = ({
gameBoardId,
isManager,
refreshSignal = 0,
onRefreshingChange,
}: GameBoardTabProps) => {
const [isLoading, setIsLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [members, setMembers] = useState<GameMember[]>([]);
Expand Down Expand Up @@ -162,6 +171,50 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
return () => clearInterval(id);
}, []);

// 소켓이 (재)연결되면 그동안 놓친 변경을 REST로 백필한다.
// 최초 연결분은 위 마운트 effect가 이미 처리하므로 건너뛴다.
const hasConnectedRef = useRef(false);
useEffect(() => {
if (!gameWs.isOpen) return;
if (!hasConnectedRef.current) {
hasConnectedRef.current = true;
return;
}
getGameBoard(gameBoardId)
.then(applyBoard)
.catch(() => {});
refreshMembers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameWs.isOpen, gameBoardId]);

// 헤더 새로고침 버튼: 보드/명단을 REST로 다시 불러온다. (초기값 0은 무시)
useEffect(() => {
if (!refreshSignal) return;
let cancelled = false;
onRefreshingChange?.(true);
Promise.all([
getGameBoard(gameBoardId).then(board => {
if (!cancelled) applyBoard(board);
}),
getGameBoardMembers(gameBoardId, toGameBoardMembersParams(filters)).then(
res => {
if (!cancelled) applyMembersResponse(res);
},
),
// 응답이 즉시 와도 회전이 눈에 보이도록 최소 시간 확보
new Promise(resolve => setTimeout(resolve, 600)),
])
.catch(() => {})
.finally(() => {
if (!cancelled) onRefreshingChange?.(false);
});
return () => {
cancelled = true;
onRefreshingChange?.(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshSignal]);

// 다른 클라이언트의 변경사항 브로드캐스트 반영
useEffect(() => {
const msg = gameWs.lastMessage;
Expand Down Expand Up @@ -272,10 +325,12 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
const handleAddToWaitingQueue = async () => {
if (selectedMembers.length === 0) return;
try {
await gameWs.createGame({
const res = await gameWs.createGame({
gameBoardId,
gameBoardMemberIds: selectedMembers.map(m => m.id),
});
if (res.data?.board) applyBoard(res.data.board);
refreshMembers();
setSelectedIds([]);
} catch (err) {
console.error("[GAME] CREATE_GAME 실패", err);
Expand All @@ -292,6 +347,8 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
});
const restoredIds = res.data?.players.map(p => p.gameBoardMemberId);
setSelectedIds(restoredIds?.length ? restoredIds : group.memberIds);
if (res.data?.board) applyBoard(res.data.board);
refreshMembers();
} catch (err) {
console.error("[GAME] DELETE_GAME(restore) 실패", err);
alert("대기열 변경에 실패했어요.");
Expand All @@ -302,11 +359,13 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
const group = waitingGroups.find(g => g.id === id);
if (!group) return;
try {
await gameWs.deleteGame({
const res = await gameWs.deleteGame({
gameBoardId,
gameId: group.gameId,
restore: false,
});
if (res.data?.board) applyBoard(res.data.board);
refreshMembers();
} catch (err) {
console.error("[GAME] DELETE_GAME 실패", err);
alert("대기열 삭제에 실패했어요.");
Expand All @@ -317,7 +376,13 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
const group = waitingGroups.find(g => g.id === waitingGroupId);
if (!group) return;
try {
await gameWs.startGame({ gameBoardId, gameId: group.gameId, courtId });
const res = await gameWs.startGame({
gameBoardId,
gameId: group.gameId,
courtId,
});
if (res.data) applyBoard(res.data);
refreshMembers();
} catch (err) {
console.error("[GAME] START_GAME 실패", err);
alert("게임 시작에 실패했어요.");
Expand All @@ -334,7 +399,13 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
if (!group || !court?.gameId) return;
try {
await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId });
await gameWs.startGame({ gameBoardId, gameId: group.gameId, courtId });
const res = await gameWs.startGame({
gameBoardId,
gameId: group.gameId,
courtId,
});
if (res.data) applyBoard(res.data);
refreshMembers();
} catch (err) {
console.error("[GAME] 코트 교체 실패", err);
alert("코트 교체에 실패했어요.");
Expand Down Expand Up @@ -368,7 +439,12 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
const court = courts.find(c => c.id === courtId);
if (!court?.gameId) return;
try {
await gameWs.completeGame({ gameBoardId, gameId: court.gameId });
const res = await gameWs.completeGame({
gameBoardId,
gameId: court.gameId,
});
if (res.data) applyBoard(res.data);
refreshMembers();
} catch (err) {
console.error("[GAME] COMPLETE_GAME 실패", err);
alert("게임 완료에 실패했어요.");
Expand All @@ -381,7 +457,12 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
const court = courts.find(c => c.id === courtId);
if (!court?.gameId) return;
try {
await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId });
const res = await gameWs.moveToWaiting({
gameBoardId,
gameId: court.gameId,
});
if (res.data) applyBoard(res.data);
refreshMembers();
} catch (err) {
console.error("[GAME] MOVE_TO_WAITING 실패", err);
alert("대기로 되돌리기에 실패했어요.");
Expand All @@ -399,6 +480,8 @@ export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => {
});
const restoredIds = res.data?.players.map(p => p.gameBoardMemberId);
setSelectedIds(restoredIds?.length ? restoredIds : []);
if (res.data?.board) applyBoard(res.data.board);
refreshMembers();
} catch (err) {
console.error("[GAME] 경기 취소 실패", err);
alert("경기 취소에 실패했어요.");
Expand Down
16 changes: 15 additions & 1 deletion src/pages/group/MyGroupDetail/MyExerciseDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export const MyExerciseDetail = () => {

const [isWithdrawnModal, setIsWithdrawnModal] = useState(false);

// 게임판 헤더 새로고침: 값이 바뀔 때마다 GameBoardTab이 보드/명단을 다시 불러온다.
const [gameRefreshSignal, setGameRefreshSignal] = useState(0);
const [isGameRefreshing, setIsGameRefreshing] = useState(false);

const location = window.location;
type TabType = "detail" | "game" | "finished";
const [activeTab, setActiveTab] = useState<TabType>(() => {
Expand Down Expand Up @@ -206,8 +210,16 @@ export const MyExerciseDetail = () => {
<PageHeader
title="내 운동 상세"
onMoreClick={
isCurrentUserLeader ? () => setIsSortOpen(true) : undefined
activeTab === "detail" && isCurrentUserLeader
? () => setIsSortOpen(true)
: undefined
}
onRefreshClick={
activeTab === "game"
? () => setGameRefreshSignal(n => n + 1)
: undefined
}
refreshing={isGameRefreshing}
onBackClick={() => {
if (returnPath === -1) navigate(-1);
else navigate(returnPath);
Expand Down Expand Up @@ -389,6 +401,8 @@ export const MyExerciseDetail = () => {
<GameBoardTab
gameBoardId={detail.gameBoardId}
isManager={detail.isManager}
refreshSignal={gameRefreshSignal}
onRefreshingChange={setIsGameRefreshing}
/>
)}

Expand Down
Loading