Skip to content

Commit

Permalink
Merge branch 'fe-dev' into 132-대회-페이지-내의-웹소켓-연결-상태를-확인할-수-있는-기능
Browse files Browse the repository at this point in the history
  • Loading branch information
mahwin authored Nov 28, 2023
2 parents 2311b1c + 239016c commit 681eea6
Show file tree
Hide file tree
Showing 25 changed files with 597 additions and 305 deletions.
13 changes: 13 additions & 0 deletions frontend/src/apis/competitionList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import api from '@/utils/api';

import { Competition } from './types';

export const fetchCompetitionList = async (): Promise<Competition[]> => {
try {
const response = await api.get('/competitions');
return response.data;
} catch (error) {
console.error('Error fetching competitions:', (error as Error).message);
throw error;
}
};
7 changes: 7 additions & 0 deletions frontend/src/apis/competitionList/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type Competition = {
id: number;
name: string;
startsAt: string;
endsAt: string;
maxParticipants: number;
};
44 changes: 44 additions & 0 deletions frontend/src/apis/joinCompetition/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import api from '@/utils/api';

import type { CompetitionApiData } from './types';
import axios from 'axios';

const STATUS = {
Forbidden: 403,
BadRequest: 400,
} as const;

export async function joinCompetition(data: CompetitionApiData) {
const { id, token } = data;

try {
await api.post(
`/competitions/${id}/participations`,
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);

return '대회에 성공적으로 참여했습니다.';
} catch (error: unknown) {
if (!axios.isAxiosError(error)) {
return 'Unexpected error occurred';
}

if (!error.response) {
return 'Network error occurred';
}

switch (error.response.status) {
case STATUS.Forbidden:
return '대회 참여에 실패했습니다. 서버에서 거절되었습니다.';
case STATUS.BadRequest:
return '이미 참여한 대회입니다.';
default:
return `HTTP Error ${error.response.status}`;
}
}
}
4 changes: 4 additions & 0 deletions frontend/src/apis/joinCompetition/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type CompetitionApiData = {
id: number;
token: string | null;
};
74 changes: 74 additions & 0 deletions frontend/src/components/Common/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { css, cx } from '@style/css';

import type { HTMLAttributes, MouseEvent } from 'react';
import { useContext, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

import { ModalContext } from './ModalContext';
import { ModalProvider } from './ModalProvider';

export interface Props extends HTMLAttributes<HTMLDialogElement> {
onBackdropPressed?: () => void;
}

export function Modal({ onBackdropPressed, children, ...props }: Props) {
const modal = useContext(ModalContext);
const $dialog = useRef<HTMLDialogElement>(null);

const handleClickBackdrop = (e: MouseEvent<HTMLDialogElement>) => {
const $target = e.target as HTMLDialogElement;

if ($target.nodeName !== 'DIALOG') return;

if (onBackdropPressed instanceof Function) {
onBackdropPressed();
}
};

useEffect(() => {
if (modal.isOpen) {
$dialog.current?.showModal();
} else {
$dialog.current?.close();
}
}, [modal.isOpen]);

return ReactDOM.createPortal(
<dialog
ref={$dialog}
className={cx(style, dialogStyle)}
aria-modal="true"
aria-labelledby="dialog-title"
onClick={handleClickBackdrop}
{...props}
>
<div className={contentStyle}>{children}</div>
</dialog>,
document.body,
);
}

Modal.Context = ModalContext;
Modal.Provider = ModalProvider;

const style = css({
borderRadius: '0.5rem',
});

const dialogStyle = css({
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%,-50%)',
width: '500px',
height: '400px',
_backdrop: {
background: 'rgba(00,00,00,0.5)',
backdropFilter: 'blur(1rem)',
},
});

const contentStyle = css({
width: '100%',
height: '100%',
});
13 changes: 13 additions & 0 deletions frontend/src/components/Common/Modal/ModalContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext } from 'react';

interface ModalContextProps {
isOpen: boolean;
close: () => void;
open: () => void;
}

export const ModalContext = createContext<ModalContextProps>({
isOpen: false,
close: () => {},
open: () => {},
});
26 changes: 26 additions & 0 deletions frontend/src/components/Common/Modal/ModalProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { useState } from 'react';

import { ModalContext } from './ModalContext';

export function ModalProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const close = () => {
setIsOpen(false);
};
const open = () => {
setIsOpen(true);
};

return (
<ModalContext.Provider
value={{
isOpen,
close,
open,
}}
>
{children}
</ModalContext.Provider>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/Common/Modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Modal } from './Modal';
export type { Props as ModalProps } from './Modal';
1 change: 1 addition & 0 deletions frontend/src/components/Common/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Input } from './Input';
export * from './Modal';
27 changes: 27 additions & 0 deletions frontend/src/components/Contest/CompetitionHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { css } from '@style/css';

import ViewDashboardButton from '../Main/Buttons/ViewDashboardButton';
import ContestBreadCrumb from './ContestBreadCrumb';

interface Props {
crumbs: string[];
id: number;
}

export default function CompetitionHeader(props: Props) {
return (
<div className={headerStyle}>
<ContestBreadCrumb crumbs={props.crumbs} />
<ViewDashboardButton id={props.id} />
</div>
);
}

const headerStyle = css({
backgroundColor: 'gray',
color: 'black',
width: '850px',
height: '50px',
display: 'flex',
justifyContent: 'space-between',
});
29 changes: 11 additions & 18 deletions frontend/src/components/Contest/ContestBreadCrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,16 @@ export default function ContestBreadCrumb(props: Props) {
const { crumbs } = props;

return (
<header>
<div className={titleContainerStyle}>
{crumbs.map((crumb, index) => (
<span key={index} className={crumbStyle}>
{crumb}
</span>
))}
</div>
</header>
<div className={titleContainerStyle}>
{crumbs.map((crumb, index) => (
<span key={index} className={crumbStyle}>
{crumb}
</span>
))}
</div>
);
}

const titleContainerStyle = css({
backgroundColor: 'gray',
color: 'black',
padding: '10px',
width: '850px',
height: '50px',
display: 'flex',
});

const crumbStyle = css({
marginRight: '1rem',
_after: {
Expand All @@ -41,3 +30,7 @@ const crumbStyle = css({
},
},
});

const titleContainerStyle = css({
padding: '10px',
});
31 changes: 31 additions & 0 deletions frontend/src/components/Contest/ContestProblemSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { css } from '@style/css';

interface AsideProps {
problemIds: number[];
onChangeProblemIndex: (index: number) => void;
}

export default function ContestProblemSelector(props: AsideProps) {
function handleChangeProblemIndex(index: number) {
props.onChangeProblemIndex(index);
}

return (
<aside>
<span>문제 목록</span>
<ul>
{props.problemIds.map((id: number, index: number) => (
<li key={id}>
<button className={selectProblemStyle} onClick={() => handleChangeProblemIndex(index)}>
문제 {index + 1}
</button>
</li>
))}
</ul>
</aside>
);
}

const selectProblemStyle = css({
color: 'black',
});
22 changes: 15 additions & 7 deletions frontend/src/components/Main/Buttons/GoToCreateCompetitionLink.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';

import useAuth from '@/hooks/login/useAuth';

export default function GoToCreateCompetitionLink() {
// TODO: 로그인 여부에 따른 페이지 이동 설정
const { isLoggedin } = useAuth();
const navigate = useNavigate();

const handleNavigate = () => {
if (!isLoggedin) {
alert('로그인이 필요합니다.');
navigate('/login');
} else {
navigate('/contest/create');
}
};

return (
<Link to="/contest/create">
<button>대회 생성</button>
</Link>
);
return <button onClick={handleNavigate}>대회 생성</button>;
}
35 changes: 32 additions & 3 deletions frontend/src/components/Main/Buttons/JoinCompetitionButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,33 @@
export default function JoinCompetitionButton() {
// TODO: 대회에 참여하는 로직 작성 / 참여하기 활성화 로직 작성
return <button>참여하기</button>;
import { useNavigate } from 'react-router-dom';

import { joinCompetition } from '@/apis/joinCompetition';
import type { CompetitionApiData } from '@/apis/joinCompetition/types';
import useAuth from '@/hooks/login/useAuth';

const TOKEN_KEY = 'accessToken';

export default function JoinCompetitionButton(props: { id: number }) {
const { isLoggedin } = useAuth();
const navigate = useNavigate();

const queryParams = new URLSearchParams(location.search);
const token = queryParams.get(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY);

const handleJoinClick = async () => {
if (!isLoggedin) {
alert('로그인이 필요합니다.');
navigate('/login');
return;
}

const result = await joinCompetition(competitionData);
alert(result);
window.location.reload();
};
const competitionData: CompetitionApiData = {
id: props.id,
token: token,
};

return <button onClick={handleJoinClick}>참여하기</button>;
}
Loading

0 comments on commit 681eea6

Please sign in to comment.