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: 2 additions & 0 deletions apps/web/src/app/tournament/[id]/_common/_consts/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** 프리로드와 ProductCard의 next/image 캐시 키를 일치시키는 공통 sizes 값 */
export const PRODUCT_CARD_IMAGE_SIZES = '(max-width: 480px) 45vw, 200px';
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';

import { getImageProps } from 'next/image';
import { useEffect, useRef } from 'react';

import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image';

/** 로딩 페이지에서는 1라운드 후보를, 매치 화면에서는 라운드별 `remainingItems`를 프리로드한다. */
const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => {
const preloadedUrlsRef = useRef<Set<string>>(new Set());
const pendingImagesRef = useRef<Set<HTMLImageElement>>(new Set());

const urlsKey = imageUrls.filter(Boolean).join('|');

useEffect(() => {
if (!urlsKey) return;

const pendingImages = pendingImagesRef.current;

urlsKey.split('|').forEach(imageUrl => {
if (preloadedUrlsRef.current.has(imageUrl)) return;
preloadedUrlsRef.current.add(imageUrl);

const { props } = getImageProps({
src: imageUrl,
alt: '',
fill: true,
sizes: PRODUCT_CARD_IMAGE_SIZES,
});

const image = new Image();
/** 현재 매치 이미지와 대역폭을 다투지 않도록 낮은 우선순위로 */
image.fetchPriority = 'low';

if (props.sizes) image.sizes = props.sizes;
if (props.srcSet) image.srcset = props.srcSet;

image.onload = () => {
pendingImages.delete(image);
};
/** 실패한 URL은 제거해 다음 진입 시 재시도한다. */
image.onerror = () => {
pendingImages.delete(image);

preloadedUrlsRef.current.delete(imageUrl);
};

pendingImages.add(image);
image.src = props.src;
});
}, [urlsKey]);
};

export default usePreloadMatchImages;
23 changes: 23 additions & 0 deletions apps/web/src/app/tournament/[id]/loading/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
'use client';

import { useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';

import { ROUTES } from '@/consts/route';
import { TOURNAMENT_STATUS } from '@/consts/tournament';

import usePreloadMatchImages from '../_common/_hooks/usePreloadMatchImages';
import type { GetTournamentResponseT } from '../_common/_types/tournamentResponse';
import LoadingBar from './_components/LoadingBar';
import TournamentBracketAnimation from './_components/TournamentBracketAnimation';

const LOADING_DURATION_MS = 4000;

const getCandidateImageUrls = (tournament: GetTournamentResponseT | undefined) => {
if (!tournament) return [];
if (tournament.status === TOURNAMENT_STATUS.PENDING)
return tournament.pending.items.map(item => item.imageUrl);
if (tournament.status !== TOURNAMENT_STATUS.IN_PROGRESS) return [];

return tournament.inProgress
? tournament.inProgress.remainingItems.map(item => item.imageUrl)
: tournament.pending.items.map(item => item.imageUrl);
};

function TournamentLoadingPage() {
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
const tournamentId = Number(params.id);

const tournamentData = queryClient.getQueryData<GetTournamentResponseT>([
'tournament',
tournamentId,
]);

usePreloadMatchImages(getCandidateImageUrls(tournamentData));

useEffect(() => {
const timer = setTimeout(() => {
router.replace(ROUTES.TOURNAMENT_MATCH(tournamentId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import BaseImage from '@/components/base-image';
import { Z_INDEX } from '@/consts/zIndex';
import formatPrice from '@/utils/formatPrice';

import { PRODUCT_CARD_IMAGE_SIZES } from '../../_common/_consts/image';
import type { ProductT } from '../../_common/_types/tournament';

type ProductCardProps = ProductT & {
Expand Down Expand Up @@ -33,7 +34,7 @@ function ProductCard({ imageUrl, name, price, isPicked, isFinal = false, onClick
<BaseImage
src={imageUrl}
alt={name}
sizes="(max-width: 480px) 45vw, 200px"
sizes={PRODUCT_CARD_IMAGE_SIZES}
preload
className="object-cover"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import usePreloadMatchImages from '../../_common/_hooks/usePreloadMatchImages';
import type { GetTournamentInProgressResponseT } from '../../_common/_types/tournamentResponse';
import useTournament from '../_hooks/useTournament';
import MatchSkeleton from './MatchSkeleton';
Expand All @@ -17,15 +18,18 @@ type TournamentClientProps = {
function TournamentClient({ tournamentId, tournamentName, inProgress }: TournamentClientProps) {
const {
currentMatch,
remainingItems,
roundLabel,
isFinalRound,
transitionStage,
selectionEpoch,
isRecordingMatch,
handleSelect,
handleTransitionComplete,
} = useTournament({ tournamentId, tournamentName, inProgress });

// 다음 대진은 미리 알 수 없어도 후보는 알고 있다 — 라운드의 남은 아이템 이미지를 미리 받아둔다
usePreloadMatchImages(remainingItems.map(item => item.imageUrl));

const backgroundClassName = isFinalRound
? 'bg-gradient-to-b from-sky-blue-50 via-[#F5FCFF] to-white'
: 'bg-bg-layer-basement';
Expand All @@ -44,8 +48,8 @@ function TournamentClient({ tournamentId, tournamentName, inProgress }: Tourname
)}
</div>
<div className={`w-full ${isFinalRound ? 'mt-29' : 'mt-8'}`}>
{/* 다음 매치는 서버 응답으로 오므로 기록 대기 동안 스켈레톤을 보여준다 */}
{isRecordingMatch || !currentMatch ? (
{/* 기록 대기 중에는 이전 화면을 유지해 스켈레톤 깜빡임을 방지한다. */}
{!currentMatch ? (
<MatchSkeleton isFinal={isFinalRound} />
) : (
<VsSection
Expand Down
18 changes: 7 additions & 11 deletions apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type InProgressT = NonNullable<GetTournamentInProgressResponseT['inProgress']>;
const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
const router = useRouter();
const queryClient = useQueryClient();
const { postRecordMatchMutation, isPostRecordMatchPending } = usePostRecordMatch({
const { postRecordMatchMutation } = usePostRecordMatch({
tournamentId,
onSuccess: data => {
const completed = data.completed;
Expand Down Expand Up @@ -77,15 +77,14 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
const [currentMatch, setCurrentMatch] = useState<TournamentMatchT | undefined>(
inProgress.currentMatch
);
// 해당 라운드에 남은 후보 아이템 — 다음 대진 이미지 프리로드용 (usePreloadMatchImages)
const [remainingItems, setRemainingItems] = useState(inProgress.remainingItems);
// 라운드 내 진행한 매치 수 (라벨 표기용) — 라운드가 바뀌면 0 으로 초기화
const [matchIndex, setMatchIndex] = useState(0);
const [transitionStage, setTransitionStage] = useState<TransitionStageT | null>(null);
// 카드 선택 락 해제용 — 매치가 바뀌지 않는 기록 실패에서 VsSection 을 remount 시켜
// 재선택을 가능하게 한다 (락은 useCardSelectionAnimation 내부 상태)
const [selectionEpoch, setSelectionEpoch] = useState(0);
// 결승 기록 후 결과 페이지로 이동하는 동안 true — 라우팅이 끝나기 전에
// 방금 고른 결승 매치가 다시 그려지는 깜빡임을 막는다
const [isNavigatingToResult, setIsNavigatingToResult] = useState(false);

// 준결승/결승 바텀시트 표시 중 재조회 없이 적용할 다음 라운드 데이터
const pendingNextRoundRef = useRef<InProgressT | null>(null);
Expand All @@ -104,14 +103,16 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
queryClient.setQueryData(['tournament', tournamentId], next);

if (next.status === TOURNAMENT_STATUS.COMPLETED) {
setIsNavigatingToResult(true);
router.replace(ROUTES.TOURNAMENT_RESULT(tournamentId));
return;
}
if (next.status !== TOURNAMENT_STATUS.IN_PROGRESS || !next.inProgress) return;

const nextInProgress = next.inProgress;

// 바텀시트 분기보다 먼저 갱신 — 시트가 떠 있는 동안이 프리로드에 쓸 수 있는 시간이다
setRemainingItems(nextInProgress.remainingItems);

// 라운드 전환 — 서버의 실제 다음 라운드 수 기준으로 바텀시트 판단
if (nextInProgress.currentRound !== currentRound) {
const stage = getTransitionStage(nextInProgress.currentRound);
Expand Down Expand Up @@ -167,7 +168,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
onSuccess: async data => {
// 토너먼트 종료 — 캐시 정리(훅 onSuccess)까지 끝난 뒤 결과 페이지로
if (data.completed) {
setIsNavigatingToResult(true);
router.replace(ROUTES.TOURNAMENT_RESULT(tournamentId));
return;
}
Expand Down Expand Up @@ -228,15 +228,11 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {

return {
currentMatch,
remainingItems,
roundLabel,
isFinalRound,
transitionStage,
selectionEpoch,
/**
* 기록 요청 대기 중 — 다음 매치를 서버가 주므로 이 동안 스켈레톤을 노출한다.
* 결과 페이지로 이동하는 중에도 유지해 방금 고른 매치가 다시 보이지 않게 한다.
*/
isRecordingMatch: isPostRecordMatchPending || isNavigatingToResult,
handleSelect,
handleTransitionComplete,
};
Expand Down
Loading