= (props) => {
13 | const { toggleColorMode } = useColorMode();
14 | const text = useColorModeValue('dark', 'light');
15 | const SwitchIcon = useColorModeValue(FaMoon, FaSun);
16 |
17 | return (
18 | }
26 | aria-label={`Switch to ${text} mode`}
27 | {...props}
28 | />
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/project/web/src/components/CommonLayout.tsx:
--------------------------------------------------------------------------------
1 | import { BackgroundProps, Box } from '@chakra-ui/react';
2 | import React from 'react';
3 | import Navbar from './nav/Navbar';
4 |
5 | interface CommonLayoutProps {
6 | bg?: BackgroundProps['bg'];
7 | children: React.ReactNode;
8 | }
9 | export default function CommonLayout({
10 | children,
11 | bg,
12 | }: CommonLayoutProps): React.ReactElement {
13 | return (
14 |
15 |
16 |
25 | {children}
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/project/web/src/components/auth/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import {
3 | Box,
4 | Button,
5 | Divider,
6 | FormControl,
7 | FormErrorMessage,
8 | FormLabel,
9 | Heading,
10 | Input,
11 | Stack,
12 | Text,
13 | useColorModeValue,
14 | } from '@chakra-ui/react';
15 | import React from 'react';
16 | import { useForm } from 'react-hook-form';
17 | import { useHistory } from 'react-router-dom';
18 | import {
19 | LoginMutationVariables,
20 | useLoginMutation,
21 | } from '../../generated/graphql';
22 |
23 | export function RealLoginForm(): React.ReactElement {
24 | const {
25 | register,
26 | handleSubmit,
27 | formState: { errors },
28 | setError,
29 | } = useForm();
30 |
31 | const history = useHistory();
32 | const [login, { loading }] = useLoginMutation();
33 | const onSubmit = async (formData: LoginMutationVariables) => {
34 | const { data } = await login({ variables: formData });
35 | if (data?.login.errors) {
36 | data.login.errors.forEach((err) => {
37 | const field = 'loginInput.';
38 | setError((field + err.field) as Parameters[0], {
39 | message: err.message,
40 | });
41 | });
42 | }
43 | if (data && data.login.accessToken) {
44 | localStorage.setItem('access_token', data.login.accessToken);
45 | history.push('/');
46 | }
47 | };
48 |
49 | return (
50 |
56 |
57 |
58 | 이메일 또는 아이디
59 |
66 |
67 | {errors.loginInput?.emailOrUsername &&
68 | errors.loginInput.emailOrUsername.message}
69 |
70 |
71 |
72 |
73 | 암호
74 |
81 |
82 | {errors.loginInput?.password && errors.loginInput.password.message}
83 |
84 |
85 |
86 |
87 |
88 |
91 |
92 |
93 | );
94 | }
95 |
96 | function LoginForm(): React.ReactElement {
97 | return (
98 |
99 |
100 | 지브리 명장면 프로젝트
101 |
102 | 감상평과 좋아요를 눌러보세요!
103 |
104 |
105 |
106 |
107 |
108 | );
109 | }
110 |
111 | export default LoginForm;
112 |
--------------------------------------------------------------------------------
/project/web/src/components/auth/SignUpForm.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | // project/web/src/components/auth/SignUpForm.tsx
3 | import {
4 | Box,
5 | Button,
6 | Divider,
7 | FormControl,
8 | FormErrorMessage,
9 | FormLabel,
10 | Heading,
11 | Input,
12 | Stack,
13 | Text,
14 | useColorModeValue,
15 | useToast,
16 | } from '@chakra-ui/react';
17 | import { useForm } from 'react-hook-form';
18 | import { useHistory } from 'react-router-dom';
19 | import {
20 | SignUpMutationVariables,
21 | useSignUpMutation,
22 | } from '../../generated/graphql';
23 |
24 | function SignUpRealForm() {
25 | const [signUp, { loading }] = useSignUpMutation();
26 | const {
27 | register,
28 | handleSubmit,
29 | formState: { errors },
30 | } = useForm();
31 | const history = useHistory();
32 | const toast = useToast();
33 |
34 | const onSubmit = async (data: SignUpMutationVariables) => {
35 | const { signUpInput } = data;
36 | return signUp({ variables: { signUpInput } })
37 | .then((res) => {
38 | if (res.data?.signUp) {
39 | toast({ title: '회원가입을 환영합니다!', status: 'success' });
40 | history.push('/');
41 | } else {
42 | toast({
43 | title: '회원가입 도중 문제가 발생했습니다.',
44 | status: 'error',
45 | });
46 | }
47 | })
48 | .catch((err) => {
49 | toast({ title: '이메일 또는 아이디가 중복됩니다.', status: 'error' });
50 | return err;
51 | });
52 | };
53 |
54 | return (
55 |
56 |
57 | 이메일
58 |
71 |
72 | {errors.signUpInput?.email && errors.signUpInput.email.message}
73 |
74 |
75 |
76 |
77 | 아이디
78 |
85 |
86 | {errors.signUpInput?.username && errors.signUpInput.username.message}
87 |
88 |
89 |
90 |
91 | 암호
92 |
107 |
108 | {errors.signUpInput?.password && errors.signUpInput.password.message}
109 |
110 |
111 |
112 |
113 |
114 |
117 |
118 | );
119 | }
120 |
121 | export default function SignUpForm(): React.ReactElement {
122 | return (
123 |
124 |
125 | 지브리 명장면 프로젝트
126 |
127 | 가입을 환영합니다!
128 |
129 |
130 |
131 |
138 |
139 |
140 |
141 | );
142 | }
143 |
--------------------------------------------------------------------------------
/project/web/src/components/film-cut/FilmCutDetail.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AspectRatio,
3 | Box,
4 | Button,
5 | Center,
6 | Flex,
7 | Heading,
8 | HStack,
9 | Image,
10 | SimpleGrid,
11 | Text,
12 | useColorModeValue,
13 | useDisclosure,
14 | useToast,
15 | } from '@chakra-ui/react';
16 | import { useMemo } from 'react';
17 | import { FaHeart } from 'react-icons/fa';
18 | import {
19 | CutDocument,
20 | CutQuery,
21 | CutQueryVariables,
22 | useMeQuery,
23 | useVoteMutation,
24 | } from '../../generated/graphql';
25 | import { FilmCutReview } from './FilmCutReview';
26 | import FilmCutReviewDeleteAlert from './FilmCutReviewDelete';
27 | import { FilmCutReviewRegiModal } from './FilmCutReviewRegiModal';
28 |
29 | interface FilmCutDetailProps {
30 | cutImg: string;
31 | cutId: number;
32 | isVoted?: boolean;
33 | votesCount?: number;
34 | reviews: CutQuery['cutReviews'];
35 | }
36 | export function FilmCutDetail({
37 | cutImg,
38 | cutId,
39 | isVoted = false,
40 | votesCount = 0,
41 | reviews,
42 | }: FilmCutDetailProps): JSX.Element {
43 | const toast = useToast();
44 | const voteButtonColor = useColorModeValue('gray.500', 'gray.400');
45 | const [vote, { loading: voteLoading }] = useVoteMutation({
46 | variables: { cutId },
47 | update: (cache, fetchResult) => {
48 | // 'cut'Query 데이터 조회
49 | const currentCut = cache.readQuery({
50 | query: CutDocument,
51 | variables: { cutId },
52 | });
53 | if (currentCut && currentCut.cut) {
54 | if (fetchResult.data?.vote) {
55 | // 'cut'Query 의 데이터를 재설정
56 | cache.writeQuery({
57 | query: CutDocument,
58 | variables: { cutId: currentCut.cut.id },
59 | data: {
60 | __typename: 'Query',
61 | ...currentCut,
62 | cut: {
63 | ...currentCut.cut,
64 | votesCount: isVoted
65 | ? currentCut.cut.votesCount - 1
66 | : currentCut.cut.votesCount + 1,
67 | isVoted: !isVoted,
68 | },
69 | },
70 | });
71 | }
72 | }
73 | },
74 | });
75 |
76 | const accessToken = localStorage.getItem('access_token');
77 | const { data: userData } = useMeQuery({ skip: !accessToken });
78 | const isLoggedIn = useMemo(() => {
79 | if (accessToken) return userData?.me?.id;
80 | return false;
81 | }, [accessToken, userData?.me?.id]);
82 |
83 | const reviewRegiDialog = useDisclosure();
84 | const deleteAlert = useDisclosure();
85 | return (
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | {cutId} 번째 사진
94 |
95 | }
99 | onClick={() => {
100 | if (isLoggedIn) vote();
101 | else {
102 | toast({
103 | status: 'warning',
104 | description: '좋아요 표시는 로그인한 이후 가능합니다.',
105 | });
106 | }
107 | }}
108 | isLoading={voteLoading}
109 | >
110 | {votesCount}
111 |
112 |
115 |
116 |
117 |
118 | {/* 감상 목록 */}
119 |
120 | {!reviews || reviews.length === 0 ? (
121 |
122 | 제일 먼저 감상을 남겨보세요!
123 |
124 | ) : (
125 |
126 | {reviews.slice(0, 2).map((review) => (
127 |
135 | ))}
136 |
137 | )}
138 |
139 |
140 |
141 |
146 | review.isMine)}
148 | isOpen={deleteAlert.isOpen}
149 | onClose={deleteAlert.onClose}
150 | />
151 |
152 | );
153 | }
154 |
--------------------------------------------------------------------------------
/project/web/src/components/film-cut/FilmCutList.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Image,
4 | LinkBox,
5 | LinkOverlay,
6 | SimpleGrid,
7 | Spinner,
8 | } from '@chakra-ui/react';
9 | import LazyLoad from 'react-lazyload';
10 | import { useCutsQuery } from '../../generated/graphql';
11 |
12 | interface FilmCutListProps {
13 | filmId: number;
14 | onClick: (cutId: number) => void;
15 | }
16 |
17 | function FilmCutList({
18 | filmId,
19 | onClick,
20 | }: FilmCutListProps): React.ReactElement {
21 | const { data, loading } = useCutsQuery({ variables: { filmId } });
22 |
23 | if (loading) {
24 | return (
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | return (
32 |
33 | {data?.cuts.map((cut) => (
34 |
35 |
36 |
37 | onClick(cut.id)}>
38 |
39 |
40 |
41 |
42 |
43 | ))}
44 |
45 | );
46 | }
47 |
48 | export default FilmCutList;
49 |
--------------------------------------------------------------------------------
/project/web/src/components/film-cut/FilmCutModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Center,
3 | Modal,
4 | ModalBody,
5 | ModalCloseButton,
6 | ModalContent,
7 | ModalHeader,
8 | ModalOverlay,
9 | Spinner,
10 | useBreakpointValue,
11 | } from '@chakra-ui/react';
12 | import React from 'react';
13 | import { useCutQuery } from '../../generated/graphql';
14 | import { FilmCutDetail } from './FilmCutDetail';
15 |
16 | interface FilmCutModalProps {
17 | open: boolean;
18 | onClose: () => void;
19 | cutId: number;
20 | }
21 |
22 | function FilmCutModal({
23 | open,
24 | onClose,
25 | cutId,
26 | }: FilmCutModalProps): React.ReactElement {
27 | const { loading, data } = useCutQuery({
28 | variables: { cutId: Number(cutId) },
29 | });
30 |
31 | const modalSize = useBreakpointValue({ base: 'full', md: 'xl' });
32 |
33 | return (
34 |
41 |
42 |
43 | {data?.cut?.film?.title}
44 |
45 |
46 | {loading && (
47 |
48 |
49 |
50 | )}
51 | {!loading && !data && 데이터를 불러오지 못했습니다.}
52 | {data && data.cut && (
53 |
60 | )}
61 |
62 |
63 |
64 | );
65 | }
66 |
67 | export default FilmCutModal;
68 |
--------------------------------------------------------------------------------
/project/web/src/components/film-cut/FilmCutReview.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | Box,
4 | Divider,
5 | Flex,
6 | HStack,
7 | IconButton,
8 | Text,
9 | Tooltip,
10 | } from '@chakra-ui/react';
11 | import { MdDelete, MdEdit } from 'react-icons/md';
12 |
13 | interface FilmCutReviewProps {
14 | author: string;
15 | isMine: boolean;
16 | contents: string;
17 | onEditClick: () => void;
18 | onDeleteClick: () => void;
19 | }
20 | export function FilmCutReview({
21 | author,
22 | isMine,
23 | contents,
24 | onEditClick,
25 | onDeleteClick,
26 | }: FilmCutReviewProps): JSX.Element {
27 | return (
28 |
29 |
30 |
31 |
32 | {author}
33 |
34 | {isMine && (
35 |
36 |
37 | }
42 | onClick={onEditClick}
43 | />
44 |
45 |
46 | }
51 | onClick={onDeleteClick}
52 | />
53 |
54 |
55 | )}
56 |
57 |
58 |
59 | {contents}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/project/web/src/components/film-cut/FilmCutReviewDelete.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogOverlay,
4 | AlertDialogContent,
5 | AlertDialogHeader,
6 | AlertDialogBody,
7 | AlertDialogFooter,
8 | Button,
9 | } from '@chakra-ui/react';
10 | import React, { useRef } from 'react';
11 | import { CutQuery, useDeleteCutReviewMutation } from '../../generated/graphql';
12 |
13 | interface FilmCutReviewDeleteAlertProps {
14 | target?: CutQuery['cutReviews'][0];
15 | isOpen: boolean;
16 | onClose: () => void;
17 | }
18 | function FilmCutReviewDeleteAlert({
19 | target,
20 | isOpen,
21 | onClose,
22 | }: FilmCutReviewDeleteAlertProps): React.ReactElement {
23 | const cancelRef = useRef(null);
24 | const [deleteCutReview] = useDeleteCutReviewMutation();
25 | async function handleDelete() {
26 | if (target) {
27 | await deleteCutReview({
28 | variables: { id: target.id },
29 | update: (cache) => {
30 | cache.evict({ id: `CutReview:${target.id}` });
31 | },
32 | });
33 | onClose();
34 | }
35 | }
36 | return (
37 |
42 |
43 |
44 |
45 | 감상 삭제
46 |
47 |
48 |
49 | 감상을 삭제하시겠습니까? 되돌릴 수 없습니다.
50 |
51 |
52 |
53 |
56 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 | export default FilmCutReviewDeleteAlert;
66 |
--------------------------------------------------------------------------------
/project/web/src/components/film-cut/FilmCutReviewRegiModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | ButtonGroup,
4 | FormControl,
5 | FormErrorMessage,
6 | Modal,
7 | ModalBody,
8 | ModalContent,
9 | ModalFooter,
10 | ModalHeader,
11 | ModalOverlay,
12 | Textarea,
13 | useToast,
14 | } from '@chakra-ui/react';
15 | import { useForm } from 'react-hook-form';
16 | import {
17 | CreateOrUpdateCutReviewMutationVariables as CutReviewVars,
18 | CutDocument,
19 | CutQuery,
20 | useCreateOrUpdateCutReviewMutation as useCreateCutReview,
21 | } from '../../generated/graphql';
22 |
23 | export interface FilmCutReviewRegiModalProps {
24 | cutId: number;
25 | isOpen: boolean;
26 | onClose: () => void;
27 | }
28 | export function FilmCutReviewRegiModal({
29 | cutId,
30 | isOpen,
31 | onClose,
32 | }: FilmCutReviewRegiModalProps): JSX.Element {
33 | const toast = useToast();
34 | const [mutation, { loading }] = useCreateCutReview();
35 | const {
36 | register,
37 | handleSubmit,
38 | formState: { errors },
39 | } = useForm({
40 | defaultValues: {
41 | cutReviewInput: { cutId },
42 | },
43 | });
44 | function onSubmit(formData: CutReviewVars): void {
45 | mutation({
46 | variables: formData,
47 | update: (cache, { data }) => {
48 | if (data && data.createOrUpdateCutReview) {
49 | const currentCut = cache.readQuery({
50 | query: CutDocument,
51 | variables: { cutId },
52 | });
53 | if (currentCut) {
54 | const isEdited = currentCut.cutReviews
55 | .map((review) => review.id)
56 | .includes(data.createOrUpdateCutReview.id);
57 | if (isEdited) {
58 | cache.evict({
59 | id: `CutReview:${data.createOrUpdateCutReview.id}`,
60 | });
61 | }
62 | cache.writeQuery({
63 | query: CutDocument,
64 | data: {
65 | ...currentCut,
66 | cutReviews: isEdited
67 | ? [...currentCut.cutReviews]
68 | : [
69 | data.createOrUpdateCutReview,
70 | ...currentCut.cutReviews.slice(0, 1),
71 | ],
72 | },
73 | variables: { cutId },
74 | });
75 | }
76 | }
77 | },
78 | })
79 | .then(onClose)
80 | .catch(() => {
81 | toast({ title: '감상평 등록 실패', status: 'error' });
82 | });
83 | }
84 | return (
85 |
86 |
87 |
88 | 감상남기기
89 |
90 |
91 |
102 |
103 | {errors.cutReviewInput?.contents?.message}
104 |
105 |
106 |
107 |
108 |
109 |
112 |
113 |
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/project/web/src/components/film/FIlmDetail.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Heading, Image, Tag, Text } from '@chakra-ui/react';
2 | import { FilmQuery } from '../../generated/graphql';
3 |
4 | interface FilmDetailProps {
5 | film?: FilmQuery['film'];
6 | }
7 |
8 | export default function FilmDetail({
9 | film,
10 | }: FilmDetailProps): React.ReactElement {
11 | return (
12 |
17 |
18 |
19 |
20 |
21 |
29 |
30 | {film?.genre.split(',').map((genre) => (
31 |
32 | {genre}
33 |
34 | ))}
35 |
36 |
37 | {film?.title}
38 | {film?.release ? `(${new Date(film?.release).getFullYear()})` : null}
39 |
40 |
41 | {film?.subtitle}
42 |
43 |
44 | {film?.director.name}
45 | {' • '}
46 | {!film ? '' : `${film?.runningTime} 분`}
47 |
48 | {film?.description}
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/project/web/src/components/film/FilmCard.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AspectRatio,
3 | Box,
4 | Heading,
5 | Image,
6 | LinkBox,
7 | LinkOverlay,
8 | Stack,
9 | Text,
10 | useColorModeValue,
11 | } from '@chakra-ui/react';
12 | import React from 'react';
13 | import { Link } from 'react-router-dom';
14 | import { FilmsQuery } from '../../generated/graphql';
15 |
16 | interface FilmCardProps {
17 | film: FilmsQuery['films']['films'][0];
18 | }
19 | export default function FilmCard({ film }: FilmCardProps): React.ReactElement {
20 | return (
21 |
22 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
42 | {film.title}
43 |
44 |
45 |
46 | {film.subtitle ? film.subtitle : <> >}
47 |
48 |
49 |
50 |
51 | {`${film.release} · ${film.runningTime}분`}
52 |
53 | {film.director.name}
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/project/web/src/components/film/FilmList.tsx:
--------------------------------------------------------------------------------
1 | import { Box, SimpleGrid, Skeleton } from '@chakra-ui/react';
2 | import { Waypoint } from 'react-waypoint';
3 | import { useFilmsQuery } from '../../generated/graphql';
4 | import FilmCard from './FilmCard';
5 |
6 | export default function FilmList(): JSX.Element {
7 | const LIMIT = 6;
8 | const { data, loading, error, fetchMore } = useFilmsQuery({
9 | variables: {
10 | limit: LIMIT,
11 | cursor: 1,
12 | },
13 | });
14 |
15 | if (error) return {error.message}
;
16 |
17 | return (
18 |
19 | {loading &&
20 | // eslint-disable-next-line react/no-array-index-key
21 | new Array(6).map((x, i) => )}
22 | {!loading &&
23 | data &&
24 | data.films.films.map((film, i) => (
25 |
26 | {data.films.cursor && i === data.films.films.length - LIMIT / 2 && (
27 | {
29 | fetchMore({
30 | variables: {
31 | limit: LIMIT,
32 | cursor: data.films.cursor,
33 | },
34 | });
35 | }}
36 | />
37 | )}
38 |
39 |
40 | ))}
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/project/web/src/components/nav/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { useApolloClient } from '@apollo/client';
2 | import {
3 | Avatar,
4 | Box,
5 | Button,
6 | Flex,
7 | Link,
8 | Menu,
9 | MenuButton,
10 | MenuItem,
11 | MenuList,
12 | Stack,
13 | Text,
14 | useColorModeValue,
15 | } from '@chakra-ui/react';
16 | import { useMemo } from 'react';
17 | import { Link as RouterLink } from 'react-router-dom';
18 | import {
19 | useLogoutMutation,
20 | useMeQuery,
21 | useUploadProfileImageMutation,
22 | } from '../../generated/graphql';
23 | import { ColorModeSwitcher } from '../ColorModeSwitcher';
24 | import Notification from '../notification/Notification';
25 |
26 | const LoggedInNavbarItem = (): JSX.Element => {
27 | const client = useApolloClient();
28 | const [logout, { loading: logoutLoading }] = useLogoutMutation();
29 |
30 | async function onLogoutClick() {
31 | try {
32 | await logout();
33 | localStorage.removeItem('access_token');
34 | await client.resetStore();
35 | } catch (e) {
36 | console.log(e);
37 | }
38 | }
39 | const [upload] = useUploadProfileImageMutation();
40 | async function handleImageUpload(e: React.ChangeEvent) {
41 | if (e.target.files) {
42 | const file = e.target.files[0];
43 | await upload({
44 | variables: { file },
45 | update: (cache) => {
46 | cache.evict({ fieldName: 'me' });
47 | },
48 | });
49 | }
50 | }
51 |
52 | const accessToken = localStorage.getItem('access_token');
53 | const { data } = useMeQuery({ skip: !accessToken });
54 | const profileImage = useMemo(() => {
55 | if (data?.me?.profileImage) {
56 | return `${process.env.REACT_APP_API_HOST}/${data?.me?.profileImage}`;
57 | }
58 | return '';
59 | }, [data]);
60 | return (
61 |
62 |
63 |
64 |
90 |
91 | );
92 | };
93 |
94 | export default function Navbar(): JSX.Element {
95 | const accessToken = localStorage.getItem('access_token');
96 | const { data } = useMeQuery({ skip: !accessToken });
97 | const isLoggedIn = useMemo(() => {
98 | if (accessToken) return data?.me?.id;
99 | return false;
100 | }, [accessToken, data?.me?.id]);
101 |
102 | return (
103 |
114 |
121 |
122 |
129 | GhibliBestCut
130 |
131 |
132 |
133 | {isLoggedIn ? (
134 |
135 | ) : (
136 |
137 |
138 |
147 |
158 |
159 | )}
160 |
161 |
162 | );
163 | }
164 |
--------------------------------------------------------------------------------
/project/web/src/components/notification/Notification.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | CircularProgress,
4 | IconButton,
5 | Menu,
6 | MenuButton,
7 | MenuDivider,
8 | MenuList,
9 | Text,
10 | useToast,
11 | } from '@chakra-ui/react';
12 | import React, { useEffect } from 'react';
13 | import { FaBell } from 'react-icons/fa';
14 | import {
15 | NewNotificationDocument,
16 | NewNotificationSubscription,
17 | useNotificationsQuery,
18 | } from '../../generated/graphql';
19 | import NotificationItem from './NotificationItem';
20 |
21 | function Notification(): React.ReactElement {
22 | const { data, loading, subscribeToMore } = useNotificationsQuery();
23 |
24 | const toast = useToast({
25 | status: 'info',
26 | position: 'top-right',
27 | isClosable: true,
28 | });
29 | useEffect(() => {
30 | if (subscribeToMore) {
31 | subscribeToMore({
32 | document: NewNotificationDocument,
33 | updateQuery: (prev, { subscriptionData }) => {
34 | if (!subscriptionData.data) return prev;
35 | const newNoti = subscriptionData.data.newNotification;
36 | toast({
37 | title: `새 알림이 도착했습니다!`,
38 | description:
39 | newNoti.text.length > 30
40 | ? `${newNoti.text.slice(0, 30)}...`
41 | : newNoti.text,
42 | });
43 | return {
44 | __typename: 'Query',
45 | notifications: [newNoti, ...prev.notifications],
46 | };
47 | },
48 | });
49 | }
50 | // eslint-disable-next-line react-hooks/exhaustive-deps
51 | }, [subscribeToMore]);
52 |
53 | return (
54 |
89 | );
90 | }
91 |
92 | export default Notification;
93 |
--------------------------------------------------------------------------------
/project/web/src/components/notification/NotificationItem.tsx:
--------------------------------------------------------------------------------
1 | import { Box, MenuItem, Text } from '@chakra-ui/react';
2 | import React from 'react';
3 | import { Notification as INotification } from '../../generated/graphql';
4 |
5 | interface NotificationItemProps {
6 | notification: INotification;
7 | }
8 |
9 | function NotificationItem({
10 | notification,
11 | }: NotificationItemProps): React.ReactElement {
12 | return (
13 |
21 | );
22 | }
23 | export default NotificationItem;
24 |
--------------------------------------------------------------------------------
/project/web/src/generated/graphql.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { gql } from '@apollo/client';
3 | import * as Apollo from '@apollo/client';
4 | export type Maybe = T | null;
5 | export type Exact = { [K in keyof T]: T[K] };
6 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe };
7 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe };
8 | const defaultOptions = {}
9 | /** All built-in and custom scalars, mapped to their actual values */
10 | export type Scalars = {
11 | ID: string;
12 | String: string;
13 | Boolean: boolean;
14 | Int: number;
15 | Float: number;
16 | /** The `Upload` scalar type represents a file upload. */
17 | Upload: any;
18 | };
19 |
20 | export type CreateOrUpdateCutReviewInput = {
21 | /** 명장면 번호 */
22 | cutId: Scalars['Int'];
23 | /** 감상평 내용 */
24 | contents: Scalars['String'];
25 | };
26 |
27 | export type Cut = {
28 | __typename?: 'Cut';
29 | /** 명장면 고유 아이디 */
30 | id: Scalars['Int'];
31 | /** 명장면 사진 주소 */
32 | src: Scalars['String'];
33 | /** 영화 아이디 */
34 | filmId: Scalars['Int'];
35 | film?: Maybe;
36 | votesCount: Scalars['Int'];
37 | isVoted: Scalars['Boolean'];
38 | };
39 |
40 | export type CutReview = {
41 | __typename?: 'CutReview';
42 | id: Scalars['Int'];
43 | /** 감상평 내용 */
44 | contents: Scalars['String'];
45 | /** 명장면 번호 */
46 | cutId: Scalars['Int'];
47 | user: User;
48 | /** 생성 일자 */
49 | createdAt: Scalars['String'];
50 | /** 수정 일자 */
51 | updatedAt: Scalars['String'];
52 | isMine: Scalars['Boolean'];
53 | };
54 |
55 | export type Director = {
56 | __typename?: 'Director';
57 | id: Scalars['Int'];
58 | name: Scalars['String'];
59 | };
60 |
61 | /** 필드 에러 타입 */
62 | export type FieldError = {
63 | __typename?: 'FieldError';
64 | field: Scalars['String'];
65 | message: Scalars['String'];
66 | };
67 |
68 | export type Film = {
69 | __typename?: 'Film';
70 | /** 영화 고유 아이디 */
71 | id: Scalars['Int'];
72 | /** 영화 제목 */
73 | title: Scalars['String'];
74 | /** 영화 부제목 */
75 | subtitle?: Maybe;
76 | /** 영화 장르 */
77 | genre: Scalars['String'];
78 | /** 영화 러닝 타임, minute */
79 | runningTime: Scalars['Float'];
80 | /** 영화 줄거리 및 설명 */
81 | description: Scalars['String'];
82 | /** 제작자 고유 아이디 */
83 | director_id: Scalars['Int'];
84 | /** 포스터 이미지 URL */
85 | posterImg: Scalars['String'];
86 | /** 개봉일 */
87 | release: Scalars['String'];
88 | director: Director;
89 | };
90 |
91 | /** 로그인 인풋 데이터 */
92 | export type LoginInput = {
93 | emailOrUsername: Scalars['String'];
94 | password: Scalars['String'];
95 | };
96 |
97 | /** 로그인 반환 데이터 */
98 | export type LoginResponse = {
99 | __typename?: 'LoginResponse';
100 | errors?: Maybe>;
101 | user?: Maybe;
102 | accessToken?: Maybe;
103 | };
104 |
105 | export type Mutation = {
106 | __typename?: 'Mutation';
107 | vote: Scalars['Boolean'];
108 | createOrUpdateCutReview?: Maybe;
109 | deleteReview: Scalars['Boolean'];
110 | createNotification: Notification;
111 | signUp: User;
112 | login: LoginResponse;
113 | logout: Scalars['Boolean'];
114 | refreshAccessToken?: Maybe;
115 | uploadProfileImage: Scalars['Boolean'];
116 | };
117 |
118 |
119 | export type MutationVoteArgs = {
120 | cutId: Scalars['Int'];
121 | };
122 |
123 |
124 | export type MutationCreateOrUpdateCutReviewArgs = {
125 | cutReviewInput: CreateOrUpdateCutReviewInput;
126 | };
127 |
128 |
129 | export type MutationDeleteReviewArgs = {
130 | id: Scalars['Int'];
131 | };
132 |
133 |
134 | export type MutationCreateNotificationArgs = {
135 | text: Scalars['String'];
136 | userId: Scalars['Int'];
137 | };
138 |
139 |
140 | export type MutationSignUpArgs = {
141 | signUpInput: SignUpInput;
142 | };
143 |
144 |
145 | export type MutationLoginArgs = {
146 | loginInput: LoginInput;
147 | };
148 |
149 |
150 | export type MutationUploadProfileImageArgs = {
151 | file: Scalars['Upload'];
152 | };
153 |
154 | export type Notification = {
155 | __typename?: 'Notification';
156 | id: Scalars['Int'];
157 | text: Scalars['String'];
158 | createdAt: Scalars['String'];
159 | updatedAt: Scalars['String'];
160 | userId: Scalars['Float'];
161 | };
162 |
163 | export type PaginatedFilms = {
164 | __typename?: 'PaginatedFilms';
165 | films: Array;
166 | cursor?: Maybe;
167 | };
168 |
169 | export type Query = {
170 | __typename?: 'Query';
171 | cuts: Array;
172 | cut?: Maybe;
173 | cutReviews: Array;
174 | films: PaginatedFilms;
175 | film?: Maybe;
176 | /** 세션에 해당되는 유저의 모든 알림을 가져옵니다. */
177 | notifications: Array;
178 | me?: Maybe;
179 | };
180 |
181 |
182 | export type QueryCutsArgs = {
183 | filmId: Scalars['Int'];
184 | };
185 |
186 |
187 | export type QueryCutArgs = {
188 | cutId: Scalars['Int'];
189 | };
190 |
191 |
192 | export type QueryCutReviewsArgs = {
193 | take?: Maybe;
194 | skip?: Maybe;
195 | cutId: Scalars['Int'];
196 | };
197 |
198 |
199 | export type QueryFilmsArgs = {
200 | cursor?: Maybe;
201 | limit?: Maybe;
202 | };
203 |
204 |
205 | export type QueryFilmArgs = {
206 | filmId: Scalars['Int'];
207 | };
208 |
209 | /** 액세스 토큰 새로고침 반환 데이터 */
210 | export type RefreshAccessTokenResponse = {
211 | __typename?: 'RefreshAccessTokenResponse';
212 | accessToken: Scalars['String'];
213 | };
214 |
215 | export type SignUpInput = {
216 | email: Scalars['String'];
217 | username: Scalars['String'];
218 | password: Scalars['String'];
219 | };
220 |
221 | export type Subscription = {
222 | __typename?: 'Subscription';
223 | newNotification: Notification;
224 | };
225 |
226 |
227 | export type User = {
228 | __typename?: 'User';
229 | id: Scalars['Int'];
230 | /** 유저 이름 */
231 | username: Scalars['String'];
232 | /** 유저 이메일 */
233 | email: Scalars['String'];
234 | /** 프로필 사진 경로 */
235 | profileImage?: Maybe;
236 | /** 생성 일자 */
237 | createdAt: Scalars['String'];
238 | /** 업데이트 일자 */
239 | updatedAt: Scalars['String'];
240 | };
241 |
242 | export type CreateOrUpdateCutReviewMutationVariables = Exact<{
243 | cutReviewInput: CreateOrUpdateCutReviewInput;
244 | }>;
245 |
246 |
247 | export type CreateOrUpdateCutReviewMutation = (
248 | { __typename?: 'Mutation' }
249 | & { createOrUpdateCutReview?: Maybe<(
250 | { __typename?: 'CutReview' }
251 | & Pick
252 | & { user: (
253 | { __typename?: 'User' }
254 | & Pick
255 | ) }
256 | )> }
257 | );
258 |
259 | export type DeleteCutReviewMutationVariables = Exact<{
260 | id: Scalars['Int'];
261 | }>;
262 |
263 |
264 | export type DeleteCutReviewMutation = (
265 | { __typename?: 'Mutation' }
266 | & Pick
267 | );
268 |
269 | export type LoginMutationVariables = Exact<{
270 | loginInput: LoginInput;
271 | }>;
272 |
273 |
274 | export type LoginMutation = (
275 | { __typename?: 'Mutation' }
276 | & { login: (
277 | { __typename?: 'LoginResponse' }
278 | & Pick
279 | & { errors?: Maybe
282 | )>>, user?: Maybe<(
283 | { __typename?: 'User' }
284 | & Pick
285 | )> }
286 | ) }
287 | );
288 |
289 | export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
290 |
291 |
292 | export type LogoutMutation = (
293 | { __typename?: 'Mutation' }
294 | & Pick
295 | );
296 |
297 | export type RefreshAccessTokenMutationVariables = Exact<{ [key: string]: never; }>;
298 |
299 |
300 | export type RefreshAccessTokenMutation = (
301 | { __typename?: 'Mutation' }
302 | & { refreshAccessToken?: Maybe<(
303 | { __typename?: 'RefreshAccessTokenResponse' }
304 | & Pick
305 | )> }
306 | );
307 |
308 | export type SignUpMutationVariables = Exact<{
309 | signUpInput: SignUpInput;
310 | }>;
311 |
312 |
313 | export type SignUpMutation = (
314 | { __typename?: 'Mutation' }
315 | & { signUp: (
316 | { __typename?: 'User' }
317 | & Pick
318 | ) }
319 | );
320 |
321 | export type UploadProfileImageMutationVariables = Exact<{
322 | file: Scalars['Upload'];
323 | }>;
324 |
325 |
326 | export type UploadProfileImageMutation = (
327 | { __typename?: 'Mutation' }
328 | & Pick
329 | );
330 |
331 | export type VoteMutationVariables = Exact<{
332 | cutId: Scalars['Int'];
333 | }>;
334 |
335 |
336 | export type VoteMutation = (
337 | { __typename?: 'Mutation' }
338 | & Pick
339 | );
340 |
341 | export type CutQueryVariables = Exact<{
342 | cutId: Scalars['Int'];
343 | }>;
344 |
345 |
346 | export type CutQuery = (
347 | { __typename?: 'Query' }
348 | & { cut?: Maybe<(
349 | { __typename?: 'Cut' }
350 | & Pick
351 | & { film?: Maybe<(
352 | { __typename?: 'Film' }
353 | & Pick
354 | )> }
355 | )>, cutReviews: Array<(
356 | { __typename?: 'CutReview' }
357 | & Pick
358 | & { user: (
359 | { __typename?: 'User' }
360 | & Pick
361 | ) }
362 | )> }
363 | );
364 |
365 | export type CutsQueryVariables = Exact<{
366 | filmId: Scalars['Int'];
367 | }>;
368 |
369 |
370 | export type CutsQuery = (
371 | { __typename?: 'Query' }
372 | & { cuts: Array<(
373 | { __typename?: 'Cut' }
374 | & Pick
375 | )> }
376 | );
377 |
378 | export type FilmQueryVariables = Exact<{
379 | filmId: Scalars['Int'];
380 | }>;
381 |
382 |
383 | export type FilmQuery = (
384 | { __typename?: 'Query' }
385 | & { film?: Maybe<(
386 | { __typename?: 'Film' }
387 | & Pick
388 | & { director: (
389 | { __typename?: 'Director' }
390 | & Pick
391 | ) }
392 | )> }
393 | );
394 |
395 | export type FilmsQueryVariables = Exact<{
396 | limit?: Maybe;
397 | cursor?: Maybe;
398 | }>;
399 |
400 |
401 | export type FilmsQuery = (
402 | { __typename?: 'Query' }
403 | & { films: (
404 | { __typename?: 'PaginatedFilms' }
405 | & Pick
406 | & { films: Array<(
407 | { __typename?: 'Film' }
408 | & Pick
409 | & { director: (
410 | { __typename?: 'Director' }
411 | & Pick
412 | ) }
413 | )> }
414 | ) }
415 | );
416 |
417 | export type MeQueryVariables = Exact<{ [key: string]: never; }>;
418 |
419 |
420 | export type MeQuery = (
421 | { __typename?: 'Query' }
422 | & { me?: Maybe<(
423 | { __typename?: 'User' }
424 | & Pick
425 | )> }
426 | );
427 |
428 | export type NotificationsQueryVariables = Exact<{ [key: string]: never; }>;
429 |
430 |
431 | export type NotificationsQuery = (
432 | { __typename?: 'Query' }
433 | & { notifications: Array<(
434 | { __typename?: 'Notification' }
435 | & Pick
436 | )> }
437 | );
438 |
439 | export type NewNotificationSubscriptionVariables = Exact<{ [key: string]: never; }>;
440 |
441 |
442 | export type NewNotificationSubscription = (
443 | { __typename?: 'Subscription' }
444 | & { newNotification: (
445 | { __typename?: 'Notification' }
446 | & Pick
447 | ) }
448 | );
449 |
450 |
451 | export const CreateOrUpdateCutReviewDocument = gql`
452 | mutation createOrUpdateCutReview($cutReviewInput: CreateOrUpdateCutReviewInput!) {
453 | createOrUpdateCutReview(cutReviewInput: $cutReviewInput) {
454 | contents
455 | cutId
456 | id
457 | user {
458 | username
459 | email
460 | }
461 | createdAt
462 | isMine
463 | }
464 | }
465 | `;
466 | export type CreateOrUpdateCutReviewMutationFn = Apollo.MutationFunction;
467 |
468 | /**
469 | * __useCreateOrUpdateCutReviewMutation__
470 | *
471 | * To run a mutation, you first call `useCreateOrUpdateCutReviewMutation` within a React component and pass it any options that fit your needs.
472 | * When your component renders, `useCreateOrUpdateCutReviewMutation` returns a tuple that includes:
473 | * - A mutate function that you can call at any time to execute the mutation
474 | * - An object with fields that represent the current status of the mutation's execution
475 | *
476 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
477 | *
478 | * @example
479 | * const [createOrUpdateCutReviewMutation, { data, loading, error }] = useCreateOrUpdateCutReviewMutation({
480 | * variables: {
481 | * cutReviewInput: // value for 'cutReviewInput'
482 | * },
483 | * });
484 | */
485 | export function useCreateOrUpdateCutReviewMutation(baseOptions?: Apollo.MutationHookOptions) {
486 | const options = {...defaultOptions, ...baseOptions}
487 | return Apollo.useMutation(CreateOrUpdateCutReviewDocument, options);
488 | }
489 | export type CreateOrUpdateCutReviewMutationHookResult = ReturnType;
490 | export type CreateOrUpdateCutReviewMutationResult = Apollo.MutationResult;
491 | export type CreateOrUpdateCutReviewMutationOptions = Apollo.BaseMutationOptions;
492 | export const DeleteCutReviewDocument = gql`
493 | mutation deleteCutReview($id: Int!) {
494 | deleteReview(id: $id)
495 | }
496 | `;
497 | export type DeleteCutReviewMutationFn = Apollo.MutationFunction;
498 |
499 | /**
500 | * __useDeleteCutReviewMutation__
501 | *
502 | * To run a mutation, you first call `useDeleteCutReviewMutation` within a React component and pass it any options that fit your needs.
503 | * When your component renders, `useDeleteCutReviewMutation` returns a tuple that includes:
504 | * - A mutate function that you can call at any time to execute the mutation
505 | * - An object with fields that represent the current status of the mutation's execution
506 | *
507 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
508 | *
509 | * @example
510 | * const [deleteCutReviewMutation, { data, loading, error }] = useDeleteCutReviewMutation({
511 | * variables: {
512 | * id: // value for 'id'
513 | * },
514 | * });
515 | */
516 | export function useDeleteCutReviewMutation(baseOptions?: Apollo.MutationHookOptions) {
517 | const options = {...defaultOptions, ...baseOptions}
518 | return Apollo.useMutation(DeleteCutReviewDocument, options);
519 | }
520 | export type DeleteCutReviewMutationHookResult = ReturnType;
521 | export type DeleteCutReviewMutationResult = Apollo.MutationResult;
522 | export type DeleteCutReviewMutationOptions = Apollo.BaseMutationOptions;
523 | export const LoginDocument = gql`
524 | mutation login($loginInput: LoginInput!) {
525 | login(loginInput: $loginInput) {
526 | errors {
527 | field
528 | message
529 | }
530 | user {
531 | id
532 | username
533 | email
534 | }
535 | accessToken
536 | }
537 | }
538 | `;
539 | export type LoginMutationFn = Apollo.MutationFunction;
540 |
541 | /**
542 | * __useLoginMutation__
543 | *
544 | * To run a mutation, you first call `useLoginMutation` within a React component and pass it any options that fit your needs.
545 | * When your component renders, `useLoginMutation` returns a tuple that includes:
546 | * - A mutate function that you can call at any time to execute the mutation
547 | * - An object with fields that represent the current status of the mutation's execution
548 | *
549 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
550 | *
551 | * @example
552 | * const [loginMutation, { data, loading, error }] = useLoginMutation({
553 | * variables: {
554 | * loginInput: // value for 'loginInput'
555 | * },
556 | * });
557 | */
558 | export function useLoginMutation(baseOptions?: Apollo.MutationHookOptions) {
559 | const options = {...defaultOptions, ...baseOptions}
560 | return Apollo.useMutation(LoginDocument, options);
561 | }
562 | export type LoginMutationHookResult = ReturnType;
563 | export type LoginMutationResult = Apollo.MutationResult;
564 | export type LoginMutationOptions = Apollo.BaseMutationOptions;
565 | export const LogoutDocument = gql`
566 | mutation Logout {
567 | logout
568 | }
569 | `;
570 | export type LogoutMutationFn = Apollo.MutationFunction;
571 |
572 | /**
573 | * __useLogoutMutation__
574 | *
575 | * To run a mutation, you first call `useLogoutMutation` within a React component and pass it any options that fit your needs.
576 | * When your component renders, `useLogoutMutation` returns a tuple that includes:
577 | * - A mutate function that you can call at any time to execute the mutation
578 | * - An object with fields that represent the current status of the mutation's execution
579 | *
580 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
581 | *
582 | * @example
583 | * const [logoutMutation, { data, loading, error }] = useLogoutMutation({
584 | * variables: {
585 | * },
586 | * });
587 | */
588 | export function useLogoutMutation(baseOptions?: Apollo.MutationHookOptions) {
589 | const options = {...defaultOptions, ...baseOptions}
590 | return Apollo.useMutation(LogoutDocument, options);
591 | }
592 | export type LogoutMutationHookResult = ReturnType;
593 | export type LogoutMutationResult = Apollo.MutationResult;
594 | export type LogoutMutationOptions = Apollo.BaseMutationOptions;
595 | export const RefreshAccessTokenDocument = gql`
596 | mutation refreshAccessToken {
597 | refreshAccessToken {
598 | accessToken
599 | }
600 | }
601 | `;
602 | export type RefreshAccessTokenMutationFn = Apollo.MutationFunction;
603 |
604 | /**
605 | * __useRefreshAccessTokenMutation__
606 | *
607 | * To run a mutation, you first call `useRefreshAccessTokenMutation` within a React component and pass it any options that fit your needs.
608 | * When your component renders, `useRefreshAccessTokenMutation` returns a tuple that includes:
609 | * - A mutate function that you can call at any time to execute the mutation
610 | * - An object with fields that represent the current status of the mutation's execution
611 | *
612 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
613 | *
614 | * @example
615 | * const [refreshAccessTokenMutation, { data, loading, error }] = useRefreshAccessTokenMutation({
616 | * variables: {
617 | * },
618 | * });
619 | */
620 | export function useRefreshAccessTokenMutation(baseOptions?: Apollo.MutationHookOptions) {
621 | const options = {...defaultOptions, ...baseOptions}
622 | return Apollo.useMutation(RefreshAccessTokenDocument, options);
623 | }
624 | export type RefreshAccessTokenMutationHookResult = ReturnType;
625 | export type RefreshAccessTokenMutationResult = Apollo.MutationResult;
626 | export type RefreshAccessTokenMutationOptions = Apollo.BaseMutationOptions;
627 | export const SignUpDocument = gql`
628 | mutation signUp($signUpInput: SignUpInput!) {
629 | signUp(signUpInput: $signUpInput) {
630 | email
631 | username
632 | createdAt
633 | updatedAt
634 | id
635 | }
636 | }
637 | `;
638 | export type SignUpMutationFn = Apollo.MutationFunction;
639 |
640 | /**
641 | * __useSignUpMutation__
642 | *
643 | * To run a mutation, you first call `useSignUpMutation` within a React component and pass it any options that fit your needs.
644 | * When your component renders, `useSignUpMutation` returns a tuple that includes:
645 | * - A mutate function that you can call at any time to execute the mutation
646 | * - An object with fields that represent the current status of the mutation's execution
647 | *
648 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
649 | *
650 | * @example
651 | * const [signUpMutation, { data, loading, error }] = useSignUpMutation({
652 | * variables: {
653 | * signUpInput: // value for 'signUpInput'
654 | * },
655 | * });
656 | */
657 | export function useSignUpMutation(baseOptions?: Apollo.MutationHookOptions) {
658 | const options = {...defaultOptions, ...baseOptions}
659 | return Apollo.useMutation(SignUpDocument, options);
660 | }
661 | export type SignUpMutationHookResult = ReturnType;
662 | export type SignUpMutationResult = Apollo.MutationResult;
663 | export type SignUpMutationOptions = Apollo.BaseMutationOptions;
664 | export const UploadProfileImageDocument = gql`
665 | mutation uploadProfileImage($file: Upload!) {
666 | uploadProfileImage(file: $file)
667 | }
668 | `;
669 | export type UploadProfileImageMutationFn = Apollo.MutationFunction;
670 |
671 | /**
672 | * __useUploadProfileImageMutation__
673 | *
674 | * To run a mutation, you first call `useUploadProfileImageMutation` within a React component and pass it any options that fit your needs.
675 | * When your component renders, `useUploadProfileImageMutation` returns a tuple that includes:
676 | * - A mutate function that you can call at any time to execute the mutation
677 | * - An object with fields that represent the current status of the mutation's execution
678 | *
679 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
680 | *
681 | * @example
682 | * const [uploadProfileImageMutation, { data, loading, error }] = useUploadProfileImageMutation({
683 | * variables: {
684 | * file: // value for 'file'
685 | * },
686 | * });
687 | */
688 | export function useUploadProfileImageMutation(baseOptions?: Apollo.MutationHookOptions) {
689 | const options = {...defaultOptions, ...baseOptions}
690 | return Apollo.useMutation(UploadProfileImageDocument, options);
691 | }
692 | export type UploadProfileImageMutationHookResult = ReturnType;
693 | export type UploadProfileImageMutationResult = Apollo.MutationResult;
694 | export type UploadProfileImageMutationOptions = Apollo.BaseMutationOptions;
695 | export const VoteDocument = gql`
696 | mutation vote($cutId: Int!) {
697 | vote(cutId: $cutId)
698 | }
699 | `;
700 | export type VoteMutationFn = Apollo.MutationFunction;
701 |
702 | /**
703 | * __useVoteMutation__
704 | *
705 | * To run a mutation, you first call `useVoteMutation` within a React component and pass it any options that fit your needs.
706 | * When your component renders, `useVoteMutation` returns a tuple that includes:
707 | * - A mutate function that you can call at any time to execute the mutation
708 | * - An object with fields that represent the current status of the mutation's execution
709 | *
710 | * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
711 | *
712 | * @example
713 | * const [voteMutation, { data, loading, error }] = useVoteMutation({
714 | * variables: {
715 | * cutId: // value for 'cutId'
716 | * },
717 | * });
718 | */
719 | export function useVoteMutation(baseOptions?: Apollo.MutationHookOptions) {
720 | const options = {...defaultOptions, ...baseOptions}
721 | return Apollo.useMutation(VoteDocument, options);
722 | }
723 | export type VoteMutationHookResult = ReturnType;
724 | export type VoteMutationResult = Apollo.MutationResult;
725 | export type VoteMutationOptions = Apollo.BaseMutationOptions;
726 | export const CutDocument = gql`
727 | query cut($cutId: Int!) {
728 | cut(cutId: $cutId) {
729 | id
730 | src
731 | film {
732 | id
733 | title
734 | }
735 | votesCount
736 | isVoted
737 | }
738 | cutReviews(cutId: $cutId) {
739 | id
740 | contents
741 | isMine
742 | user {
743 | username
744 | email
745 | }
746 | }
747 | }
748 | `;
749 |
750 | /**
751 | * __useCutQuery__
752 | *
753 | * To run a query within a React component, call `useCutQuery` and pass it any options that fit your needs.
754 | * When your component renders, `useCutQuery` returns an object from Apollo Client that contains loading, error, and data properties
755 | * you can use to render your UI.
756 | *
757 | * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
758 | *
759 | * @example
760 | * const { data, loading, error } = useCutQuery({
761 | * variables: {
762 | * cutId: // value for 'cutId'
763 | * },
764 | * });
765 | */
766 | export function useCutQuery(baseOptions: Apollo.QueryHookOptions) {
767 | const options = {...defaultOptions, ...baseOptions}
768 | return Apollo.useQuery(CutDocument, options);
769 | }
770 | export function useCutLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
771 | const options = {...defaultOptions, ...baseOptions}
772 | return Apollo.useLazyQuery(CutDocument, options);
773 | }
774 | export type CutQueryHookResult = ReturnType;
775 | export type CutLazyQueryHookResult = ReturnType;
776 | export type CutQueryResult = Apollo.QueryResult;
777 | export const CutsDocument = gql`
778 | query cuts($filmId: Int!) {
779 | cuts(filmId: $filmId) {
780 | id
781 | src
782 | }
783 | }
784 | `;
785 |
786 | /**
787 | * __useCutsQuery__
788 | *
789 | * To run a query within a React component, call `useCutsQuery` and pass it any options that fit your needs.
790 | * When your component renders, `useCutsQuery` returns an object from Apollo Client that contains loading, error, and data properties
791 | * you can use to render your UI.
792 | *
793 | * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
794 | *
795 | * @example
796 | * const { data, loading, error } = useCutsQuery({
797 | * variables: {
798 | * filmId: // value for 'filmId'
799 | * },
800 | * });
801 | */
802 | export function useCutsQuery(baseOptions: Apollo.QueryHookOptions) {
803 | const options = {...defaultOptions, ...baseOptions}
804 | return Apollo.useQuery(CutsDocument, options);
805 | }
806 | export function useCutsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
807 | const options = {...defaultOptions, ...baseOptions}
808 | return Apollo.useLazyQuery(CutsDocument, options);
809 | }
810 | export type CutsQueryHookResult = ReturnType;
811 | export type CutsLazyQueryHookResult = ReturnType;
812 | export type CutsQueryResult = Apollo.QueryResult;
813 | export const FilmDocument = gql`
814 | query film($filmId: Int!) {
815 | film(filmId: $filmId) {
816 | id
817 | title
818 | subtitle
819 | description
820 | genre
821 | runningTime
822 | posterImg
823 | release
824 | director {
825 | id
826 | name
827 | }
828 | }
829 | }
830 | `;
831 |
832 | /**
833 | * __useFilmQuery__
834 | *
835 | * To run a query within a React component, call `useFilmQuery` and pass it any options that fit your needs.
836 | * When your component renders, `useFilmQuery` returns an object from Apollo Client that contains loading, error, and data properties
837 | * you can use to render your UI.
838 | *
839 | * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
840 | *
841 | * @example
842 | * const { data, loading, error } = useFilmQuery({
843 | * variables: {
844 | * filmId: // value for 'filmId'
845 | * },
846 | * });
847 | */
848 | export function useFilmQuery(baseOptions: Apollo.QueryHookOptions) {
849 | const options = {...defaultOptions, ...baseOptions}
850 | return Apollo.useQuery(FilmDocument, options);
851 | }
852 | export function useFilmLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
853 | const options = {...defaultOptions, ...baseOptions}
854 | return Apollo.useLazyQuery(FilmDocument, options);
855 | }
856 | export type FilmQueryHookResult = ReturnType;
857 | export type FilmLazyQueryHookResult = ReturnType;
858 | export type FilmQueryResult = Apollo.QueryResult;
859 | export const FilmsDocument = gql`
860 | query Films($limit: Int, $cursor: Int) {
861 | films(limit: $limit, cursor: $cursor) {
862 | cursor
863 | films {
864 | id
865 | title
866 | subtitle
867 | runningTime
868 | director {
869 | name
870 | }
871 | release
872 | posterImg
873 | }
874 | }
875 | }
876 | `;
877 |
878 | /**
879 | * __useFilmsQuery__
880 | *
881 | * To run a query within a React component, call `useFilmsQuery` and pass it any options that fit your needs.
882 | * When your component renders, `useFilmsQuery` returns an object from Apollo Client that contains loading, error, and data properties
883 | * you can use to render your UI.
884 | *
885 | * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
886 | *
887 | * @example
888 | * const { data, loading, error } = useFilmsQuery({
889 | * variables: {
890 | * limit: // value for 'limit'
891 | * cursor: // value for 'cursor'
892 | * },
893 | * });
894 | */
895 | export function useFilmsQuery(baseOptions?: Apollo.QueryHookOptions) {
896 | const options = {...defaultOptions, ...baseOptions}
897 | return Apollo.useQuery(FilmsDocument, options);
898 | }
899 | export function useFilmsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
900 | const options = {...defaultOptions, ...baseOptions}
901 | return Apollo.useLazyQuery(FilmsDocument, options);
902 | }
903 | export type FilmsQueryHookResult = ReturnType;
904 | export type FilmsLazyQueryHookResult = ReturnType;
905 | export type FilmsQueryResult = Apollo.QueryResult;
906 | export const MeDocument = gql`
907 | query me {
908 | me {
909 | id
910 | username
911 | email
912 | profileImage
913 | updatedAt
914 | createdAt
915 | }
916 | }
917 | `;
918 |
919 | /**
920 | * __useMeQuery__
921 | *
922 | * To run a query within a React component, call `useMeQuery` and pass it any options that fit your needs.
923 | * When your component renders, `useMeQuery` returns an object from Apollo Client that contains loading, error, and data properties
924 | * you can use to render your UI.
925 | *
926 | * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
927 | *
928 | * @example
929 | * const { data, loading, error } = useMeQuery({
930 | * variables: {
931 | * },
932 | * });
933 | */
934 | export function useMeQuery(baseOptions?: Apollo.QueryHookOptions) {
935 | const options = {...defaultOptions, ...baseOptions}
936 | return Apollo.useQuery(MeDocument, options);
937 | }
938 | export function useMeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
939 | const options = {...defaultOptions, ...baseOptions}
940 | return Apollo.useLazyQuery(MeDocument, options);
941 | }
942 | export type MeQueryHookResult = ReturnType;
943 | export type MeLazyQueryHookResult = ReturnType;
944 | export type MeQueryResult = Apollo.QueryResult;
945 | export const NotificationsDocument = gql`
946 | query notifications {
947 | notifications {
948 | id
949 | userId
950 | text
951 | createdAt
952 | updatedAt
953 | }
954 | }
955 | `;
956 |
957 | /**
958 | * __useNotificationsQuery__
959 | *
960 | * To run a query within a React component, call `useNotificationsQuery` and pass it any options that fit your needs.
961 | * When your component renders, `useNotificationsQuery` returns an object from Apollo Client that contains loading, error, and data properties
962 | * you can use to render your UI.
963 | *
964 | * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
965 | *
966 | * @example
967 | * const { data, loading, error } = useNotificationsQuery({
968 | * variables: {
969 | * },
970 | * });
971 | */
972 | export function useNotificationsQuery(baseOptions?: Apollo.QueryHookOptions) {
973 | const options = {...defaultOptions, ...baseOptions}
974 | return Apollo.useQuery(NotificationsDocument, options);
975 | }
976 | export function useNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
977 | const options = {...defaultOptions, ...baseOptions}
978 | return Apollo.useLazyQuery(NotificationsDocument, options);
979 | }
980 | export type NotificationsQueryHookResult = ReturnType;
981 | export type NotificationsLazyQueryHookResult = ReturnType;
982 | export type NotificationsQueryResult = Apollo.QueryResult;
983 | export const NewNotificationDocument = gql`
984 | subscription newNotification {
985 | newNotification {
986 | id
987 | userId
988 | text
989 | createdAt
990 | updatedAt
991 | }
992 | }
993 | `;
994 |
995 | /**
996 | * __useNewNotificationSubscription__
997 | *
998 | * To run a query within a React component, call `useNewNotificationSubscription` and pass it any options that fit your needs.
999 | * When your component renders, `useNewNotificationSubscription` returns an object from Apollo Client that contains loading, error, and data properties
1000 | * you can use to render your UI.
1001 | *
1002 | * @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
1003 | *
1004 | * @example
1005 | * const { data, loading, error } = useNewNotificationSubscription({
1006 | * variables: {
1007 | * },
1008 | * });
1009 | */
1010 | export function useNewNotificationSubscription(baseOptions?: Apollo.SubscriptionHookOptions) {
1011 | const options = {...defaultOptions, ...baseOptions}
1012 | return Apollo.useSubscription(NewNotificationDocument, options);
1013 | }
1014 | export type NewNotificationSubscriptionHookResult = ReturnType;
1015 | export type NewNotificationSubscriptionResult = Apollo.SubscriptionResult;
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/createOrUpdateCutReview.graphql:
--------------------------------------------------------------------------------
1 | mutation createOrUpdateCutReview($cutReviewInput: CreateOrUpdateCutReviewInput!) {
2 | createOrUpdateCutReview(cutReviewInput: $cutReviewInput) {
3 | contents
4 | cutId
5 | id
6 | user {
7 | username
8 | email
9 | }
10 | createdAt
11 | isMine
12 | }
13 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/deleteCutReview.graphql:
--------------------------------------------------------------------------------
1 | mutation deleteCutReview($id: Int!) {
2 | deleteReview(id: $id)
3 | }
4 |
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/logIn.graphql:
--------------------------------------------------------------------------------
1 |
2 | mutation login($loginInput: LoginInput!) {
3 | login(loginInput: $loginInput) {
4 | errors {
5 | field
6 | message
7 | }
8 | user {
9 | id
10 | username
11 | email
12 | }
13 | accessToken
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/logout.graphql:
--------------------------------------------------------------------------------
1 | mutation Logout {
2 | logout
3 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/refreshAccessToken.graphql:
--------------------------------------------------------------------------------
1 | mutation refreshAccessToken {
2 | refreshAccessToken {
3 | accessToken
4 | }
5 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/signUp.graphql:
--------------------------------------------------------------------------------
1 | mutation signUp($signUpInput: SignUpInput!) {
2 | signUp(signUpInput: $signUpInput) {
3 | email
4 | username
5 | createdAt
6 | updatedAt
7 | id
8 | }
9 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/uploadProfileImage.graphql:
--------------------------------------------------------------------------------
1 | mutation uploadProfileImage($file: Upload!) {
2 | uploadProfileImage(file: $file)
3 | }
4 |
--------------------------------------------------------------------------------
/project/web/src/graphql/mutations/vote.graphql:
--------------------------------------------------------------------------------
1 | mutation vote($cutId: Int!) {
2 | vote(cutId: $cutId)
3 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/queries/cut.graphql:
--------------------------------------------------------------------------------
1 | query cut($cutId: Int!) {
2 | cut(cutId: $cutId) {
3 | id
4 | src
5 | film {
6 | id
7 | title
8 | }
9 | votesCount
10 | isVoted
11 | }
12 | # cutReviews 필드 추가
13 | cutReviews(cutId: $cutId) {
14 | id
15 | contents
16 | isMine
17 | user {
18 | username
19 | email
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/project/web/src/graphql/queries/cuts.graphql:
--------------------------------------------------------------------------------
1 | query cuts($filmId: Int!) {
2 | cuts(filmId: $filmId) {
3 | id
4 | src
5 | }
6 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/queries/film.graphql:
--------------------------------------------------------------------------------
1 | query film($filmId: Int!) {
2 | film(filmId: $filmId) {
3 | id
4 | title
5 | subtitle
6 | description
7 | genre
8 | runningTime
9 | posterImg
10 | release
11 | director {
12 | id
13 | name
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/queries/films.graphql:
--------------------------------------------------------------------------------
1 | query Films($limit: Int, $cursor: Int) {
2 | films(limit: $limit, cursor: $cursor) {
3 | cursor
4 | films {
5 | id
6 | title
7 | subtitle
8 | runningTime
9 | director {
10 | name
11 | }
12 | release
13 | posterImg
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/project/web/src/graphql/queries/me.graphql:
--------------------------------------------------------------------------------
1 | query me {
2 | me {
3 | id
4 | username
5 | email
6 | profileImage
7 | updatedAt
8 | createdAt
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/project/web/src/graphql/queries/notifications.graphql:
--------------------------------------------------------------------------------
1 | query notifications {
2 | notifications {
3 | id
4 | userId
5 | text
6 | createdAt
7 | updatedAt
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/project/web/src/graphql/subscriptions/newNotification.graphql:
--------------------------------------------------------------------------------
1 | subscription newNotification {
2 | newNotification {
3 | id
4 | userId
5 | text
6 | createdAt
7 | updatedAt
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/project/web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { ColorModeScript } from '@chakra-ui/react';
2 | import * as React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import { App } from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | ReactDOM.render(
8 |
9 |
10 |
11 | ,
12 | document.getElementById('root'),
13 | );
14 |
15 | reportWebVitals();
16 |
--------------------------------------------------------------------------------
/project/web/src/pages/Film.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Spinner, Text, useDisclosure } from '@chakra-ui/react';
2 | import React, { useState } from 'react';
3 | import { useParams } from 'react-router-dom';
4 | import CommonLayout from '../components/CommonLayout';
5 | import FilmCutList from '../components/film-cut/FilmCutList';
6 | import FilmCutModal from '../components/film-cut/FilmCutModal';
7 | import FilmDetail from '../components/film/FIlmDetail';
8 | import { useFilmQuery } from '../generated/graphql';
9 |
10 | interface FilmPageParams {
11 | filmId: string;
12 | }
13 |
14 | function Film(): React.ReactElement {
15 | const { filmId } = useParams();
16 | const { data, loading, error } = useFilmQuery({
17 | variables: { filmId: Number(filmId) },
18 | });
19 |
20 | // Selected cut, Modal states
21 | const { isOpen, onOpen, onClose } = useDisclosure();
22 | const [selectedCutId, setSelectedCutId] = useState();
23 | const handleCutSelect = (cutId: number) => {
24 | setSelectedCutId(cutId); // cut id
25 | onOpen(); // modal open
26 | };
27 |
28 | return (
29 |
30 | {loading && }
31 | {error && 페이지를 표시할 수 없습니다.}
32 | {filmId && data?.film ? (
33 | <>
34 |
35 |
36 |
37 |
38 | >
39 | ) : (
40 | 페이지를 표시할 수 없습니다.
41 | )}
42 |
43 | {!selectedCutId ? null : (
44 |
45 | )}
46 |
47 | );
48 | }
49 |
50 | export default Film;
51 |
--------------------------------------------------------------------------------
/project/web/src/pages/Login.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
2 | import React from 'react';
3 | import LoginForm from '../components/auth/LoginForm';
4 | import CommonLayout from '../components/CommonLayout';
5 |
6 | function Login(): React.ReactElement {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | export default Login;
19 |
--------------------------------------------------------------------------------
/project/web/src/pages/Main.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Heading } from '@chakra-ui/react';
2 | import CommonLayout from '../components/CommonLayout';
3 | import FilmList from '../components/film/FilmList';
4 |
5 | export default function Main(): React.ReactElement {
6 | return (
7 |
8 |
9 | 최고의 장면을 찾아보세요
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/project/web/src/pages/SignUp.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
2 | import React from 'react';
3 | import SignupForm from '../components/auth/SignUpForm';
4 | import CommonLayout from '../components/CommonLayout';
5 |
6 | function SignUp(): React.ReactElement {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | export default SignUp;
19 |
--------------------------------------------------------------------------------
/project/web/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/project/web/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/project/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
--------------------------------------------------------------------------------
/typo-list.md:
--------------------------------------------------------------------------------
1 | # 오타 목록
2 |
3 | 독자분들의 제보를 통해 알려진 책 내의 오타목록과 그 수정사항에 대한 문서입니다.
4 | 제보해주신 분들께 깊은 감사의 말씀 드립니다.
5 |
6 | ## 바로가기
7 |
8 | ### 114p. 코드 블럭 오타
9 |
10 | 제보: https://github.com/hwasurr/graphql-book-fullstack-project/issues/14
11 |
12 | 10번 째 줄 내용
13 |
14 | ```jsx
15 |
16 |
17 |
18 |
19 |
20 | ```
21 |
22 | 을 다음과 같이 정정합니다.
23 |
24 | ```jsx
25 |
26 |
27 |
28 |
29 |
30 | ```
31 |
32 | ### 181p. min -> minLength
33 |
34 | 제보: https://github.com/hwasurr/graphql-book-fullstack-project/issues/17
35 |
36 | 암호 필드의 길이를 검사하므로, react-hook-form의 `register` 옵션에 `min`을 `minLength`로 변경해야 합니다.
37 |
38 | ```jsx
39 |
53 | ```
54 |
55 | 을 다음과 같이 정정합니다.
56 |
57 | ```jsx
58 |
72 | ```
73 |
--------------------------------------------------------------------------------