diff --git a/frontend/src/components/Common/Modal/Modal.tsx b/frontend/src/components/Common/Modal/Modal.tsx new file mode 100644 index 0000000..d3937be --- /dev/null +++ b/frontend/src/components/Common/Modal/Modal.tsx @@ -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 { + onBackdropPressed?: () => void; +} + +export function Modal({ onBackdropPressed, children, ...props }: Props) { + const modal = useContext(ModalContext); + const $dialog = useRef(null); + + const handleClickBackdrop = (e: MouseEvent) => { + 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( + +
{children}
+
, + 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%', +}); diff --git a/frontend/src/components/Common/Modal/ModalContext.ts b/frontend/src/components/Common/Modal/ModalContext.ts new file mode 100644 index 0000000..aacb2fd --- /dev/null +++ b/frontend/src/components/Common/Modal/ModalContext.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; + +interface ModalContextProps { + isOpen: boolean; + close: () => void; + open: () => void; +} + +export const ModalContext = createContext({ + isOpen: false, + close: () => {}, + open: () => {}, +}); diff --git a/frontend/src/components/Common/Modal/ModalProvider.tsx b/frontend/src/components/Common/Modal/ModalProvider.tsx new file mode 100644 index 0000000..7938c1c --- /dev/null +++ b/frontend/src/components/Common/Modal/ModalProvider.tsx @@ -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(false); + const close = () => { + setIsOpen(false); + }; + const open = () => { + setIsOpen(true); + }; + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/Common/Modal/index.ts b/frontend/src/components/Common/Modal/index.ts new file mode 100644 index 0000000..ef6a639 --- /dev/null +++ b/frontend/src/components/Common/Modal/index.ts @@ -0,0 +1,2 @@ +export { Modal } from './Modal'; +export type { Props as ModalProps } from './Modal'; diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts index 6322cf3..7aa1ae9 100644 --- a/frontend/src/components/Common/index.ts +++ b/frontend/src/components/Common/index.ts @@ -1 +1,2 @@ export { Input } from './Input'; +export * from './Modal'; diff --git a/frontend/src/components/Simulation/SimulationInputModal.tsx b/frontend/src/components/Simulation/SimulationInputModal.tsx new file mode 100644 index 0000000..0a1a22a --- /dev/null +++ b/frontend/src/components/Simulation/SimulationInputModal.tsx @@ -0,0 +1,46 @@ +import { useContext, useState } from 'react'; + +import type { SimulationInput } from '@/hooks/simulation'; +import { deepCopy } from '@/utils/copy'; + +import { Modal, type ModalProps } from '../Common'; +import { SimulationInputList } from './SimulationInputList'; + +interface Props extends ModalProps { + simulationInputs: SimulationInput[]; + onSave: (inputs: SimulationInput[]) => void; +} + +export function SimulationInputModal({ simulationInputs, onSave, ...props }: Props) { + const modal = useContext(Modal.Context); + const [inputs, setInputs] = useState(deepCopy(simulationInputs)); + + const handleCloseModal = () => { + setInputs(simulationInputs); + modal.close(); + }; + + const handleSave = () => { + onSave(deepCopy(inputs)); + modal.close(); + }; + + const handleChangeInput = (targetId: number, newParam: string) => { + const changedSimulation = inputs.find(({ id }) => id === targetId); + if (changedSimulation) { + changedSimulation.input = newParam; + } + setInputs([...inputs]); + }; + + return ( + + + + + + ); +} diff --git a/frontend/src/hooks/simulation/index.ts b/frontend/src/hooks/simulation/index.ts index 8b89797..52b61a5 100644 --- a/frontend/src/hooks/simulation/index.ts +++ b/frontend/src/hooks/simulation/index.ts @@ -1,2 +1,2 @@ -export * from './useSimulations'; +export * from './useSimulation'; export * from './types'; diff --git a/frontend/src/hooks/simulation/useSimulation.ts b/frontend/src/hooks/simulation/useSimulation.ts new file mode 100644 index 0000000..06915bd --- /dev/null +++ b/frontend/src/hooks/simulation/useSimulation.ts @@ -0,0 +1,94 @@ +import { useEffect, useMemo, useState } from 'react'; + +import evaluator from '@/modules/evaluator'; + +import type { SimulationInput, SimulationResult } from './types'; + +export const useSimulation = () => { + const [inputs, setInputs] = useState([ + { id: 1, input: '' }, + { id: 2, input: '' }, + { id: 3, input: '' }, + { id: 4, input: '' }, + { id: 5, input: '' }, + ]); + const [results, setResults] = useState([ + { id: 1, isDone: true, input: '', output: '' }, + { id: 2, isDone: true, input: '', output: '' }, + { id: 3, isDone: true, input: '', output: '' }, + { id: 4, isDone: true, input: '', output: '' }, + { id: 5, isDone: true, input: '', output: '' }, + ]); + const isRunning = useMemo(() => { + return results.some((result) => !result.isDone); + }, [results]); + + useEffect(() => { + return evaluator.subscribe(({ result: output, error, task }) => { + if (!task) return; + + setResults((results) => { + return results.map((result) => { + if (result.id !== task.clientId) return result; + + if (error) { + return { + ...result, + isDone: true, + output: `${error.name}: ${error.message} \n${error.stack}`, + }; + } + return { + ...result, + isDone: true, + output, + }; + }); + }); + }); + }, []); + + function run(code: string) { + const tasks = inputs.map(({ id, input }) => evaluator.createEvalMessage(id, code, input)); + + const isRequestSuccess = evaluator.evaluate(tasks); + + if (!isRequestSuccess) { + return; + } + + setResults((results) => { + return results + .map((result, index) => ({ + ...result, + input: inputs[index].input, + })) + .map(toEvaluatingState); + }); + } + + function changeInputs(inputs: SimulationInput[]) { + setInputs([...inputs]); + } + + function cancel() { + evaluator.cancelEvaluation(); + } + + return { + inputs, + results, + isRunning, + run, + cancel, + changeInputs, + }; +}; + +const toEvaluatingState = (simulation: SimulationResult) => { + return { + ...simulation, + output: '계산중...', + isDone: false, + }; +}; diff --git a/frontend/src/hooks/simulation/useSimulations.ts b/frontend/src/hooks/simulation/useSimulations.ts deleted file mode 100644 index 60d6124..0000000 --- a/frontend/src/hooks/simulation/useSimulations.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import evaluator from '@/modules/evaluator'; - -import type { SimulationInput, SimulationResult } from './types'; - -export const useSimulations = () => { - const [simulationInputs, setSimulationInputs] = useState([ - { id: 1, input: '' }, - { id: 2, input: '' }, - { id: 3, input: '' }, - { id: 4, input: '' }, - { id: 5, input: '' }, - ]); - const [simulationResults, setSimulationResults] = useState([ - { id: 1, isDone: true, input: '', output: '' }, - { id: 2, isDone: true, input: '', output: '' }, - { id: 3, isDone: true, input: '', output: '' }, - { id: 4, isDone: true, input: '', output: '' }, - { id: 5, isDone: true, input: '', output: '' }, - ]); - const isSimulating = useMemo(() => { - return simulationResults.some((result) => !result.isDone); - }, [simulationResults]); - - useEffect(() => { - return evaluator.subscribe(({ result, error, task }) => { - if (!task) return; - - setSimulationResults((simulations) => { - return simulations.map((simul) => { - if (simul.id !== task.clientId) return simul; - - if (error) { - return { - ...simul, - isDone: true, - output: `${error.name}: ${error.message} \n${error.stack}`, - }; - } - return { - ...simul, - isDone: true, - output: result, - }; - }); - }); - }); - }, []); - - function runSimulation(code: string) { - const tasks = simulationInputs.map(({ id, input }) => - evaluator.createEvalMessage(id, code, input), - ); - - const isRequestSuccess = evaluator.evaluate(tasks); - - if (!isRequestSuccess) { - return; - } - - setSimulationResults((simulResults) => { - return simulResults - .map((simul, index) => ({ - ...simul, - input: simulationInputs[index].input, - })) - .map(toEvaluatingState); - }); - } - - function changeInput(targetId: number, newParam: string) { - const changedSimulation = simulationInputs.find(({ id }) => id === targetId); - if (changedSimulation) { - changedSimulation.input = newParam; - } - setSimulationInputs([...simulationInputs]); - } - - function cancelSimulation() { - evaluator.cancelEvaluation(); - } - - return { - simulationInputs, - simulationResults, - isSimulating, - runSimulation, - cancelSimulation, - changeInput, - }; -}; - -const toEvaluatingState = (simulation: SimulationResult) => { - return { - ...simulation, - output: '계산중...', - isDone: false, - }; -}; diff --git a/frontend/src/index.css b/frontend/src/index.css index b2b6068..6171fff 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,71 +1,75 @@ -@layer reset, base, tokens, recipes, utilities; - -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} +@layer reset, base, tokens, recipes, utilities; + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +html:has(dialog[open]) { + overflow: hidden; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f008af4..d67a5ef 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,12 +5,15 @@ import ReactDOM from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import AuthProvider from './components/Auth/AuthProvider'; +import { Modal } from './components/Common'; import router from './router'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/frontend/src/pages/ContestPage.tsx b/frontend/src/pages/ContestPage.tsx index 5a556c0..5352823 100644 --- a/frontend/src/pages/ContestPage.tsx +++ b/frontend/src/pages/ContestPage.tsx @@ -1,13 +1,14 @@ import { css } from '@style/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { ModalContext } from '@/components/Common/Modal/ModalContext'; import CompetitionHeader from '@/components/Contest/CompetitionHeader'; import ContestProblemSelector from '@/components/Contest/ContestProblemSelector'; import Editor from '@/components/Editor/Editor'; import ProblemViewer from '@/components/Problem/ProblemViewer'; -import { SimulationInputList } from '@/components/Simulation/SimulationInputList'; +import { SimulationInputModal } from '@/components/Simulation/SimulationInputModal'; import { SimulationResultList } from '@/components/Simulation/SimulationResultList'; import { SubmissionResult } from '@/components/Submission'; import { SITE } from '@/constants'; @@ -15,7 +16,7 @@ import type { SubmissionForm } from '@/hooks/competition'; import { useCompetition } from '@/hooks/competition'; import { useCompetitionProblem } from '@/hooks/problem'; import { useCompetitionProblemList } from '@/hooks/problem/useCompetitionProblemList'; -import { useSimulations } from '@/hooks/simulation'; +import { SimulationInput, useSimulation } from '@/hooks/simulation'; import { isNil } from '@/utils/type'; const RUN_SIMULATION = '테스트 실행'; @@ -25,15 +26,9 @@ export default function ContestPage() { const { id } = useParams<{ id: string }>(); const competitionId: number = id ? parseInt(id, 10) : -1; const [currentProblemIndex, setCurrentProblemIndex] = useState(0); + const modal = useContext(ModalContext); - const { - simulationInputs, - simulationResults, - isSimulating, - runSimulation, - changeInput, - cancelSimulation, - } = useSimulations(); + const simulation = useSimulation(); const { socket, competition, submitSolution } = useCompetition(competitionId); const { problemList } = useCompetitionProblemList(competitionId); @@ -60,15 +55,15 @@ export default function ContestPage() { }; const handleSimulate = () => { - runSimulation(code); + simulation.run(code); }; const handleSimulationCancel = () => { - cancelSimulation(); + simulation.cancel(); }; - const handleChangeInput = (id: number, newParam: string) => { - changeInput(id, newParam); + const handleSaveSimulationInputs = (simulationInputs: SimulationInput[]) => { + simulation.changeInputs(simulationInputs); }; function handleSubmitSolution() { @@ -86,6 +81,10 @@ export default function ContestPage() { submitSolution(form); } + function handleOpenModal() { + modal.open(); + } + const problems = problemList.map((problem) => problem.id); return ( @@ -102,12 +101,8 @@ export default function ContestPage() {
- - - {isSimulating ? ( + + {simulation.isRunning ? ( @@ -120,8 +115,17 @@ export default function ContestPage() {
- + +
+ ); } diff --git a/frontend/src/utils/copy/__tests__/copy.spec.ts b/frontend/src/utils/copy/__tests__/copy.spec.ts new file mode 100644 index 0000000..babd088 --- /dev/null +++ b/frontend/src/utils/copy/__tests__/copy.spec.ts @@ -0,0 +1,22 @@ +import { deepCopy } from '../index'; +import { describe, expect, it } from 'vitest'; + +describe('deepCopy', () => { + it('deepCopy는 객체를 깊은 복사한다.', () => { + const obj = { + inner: { + a: 1, + }, + }; + expect(deepCopy(obj)).not.equal(obj); + expect(deepCopy(obj).inner).not.equal(obj.inner); + }); + + it('deepCopy는 배열을 깊은 복사한다.', () => { + const obj = { inner: { a: 1 } }; + const arr = [obj]; + expect(deepCopy(arr)).not.equal(arr); + expect(deepCopy(arr)[0]).not.equal(arr[0]); + expect(deepCopy(arr)[0].inner).not.equal(obj.inner); + }); +}); diff --git a/frontend/src/utils/copy/index.ts b/frontend/src/utils/copy/index.ts new file mode 100644 index 0000000..0450899 --- /dev/null +++ b/frontend/src/utils/copy/index.ts @@ -0,0 +1,3 @@ +export function deepCopy(value: T): T { + return structuredClone(value); +}