diff --git a/.editorConfig b/.editorConfig new file mode 100644 index 000000000..1597c187e --- /dev/null +++ b/.editorConfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..6001602ff --- /dev/null +++ b/.eslintignore @@ -0,0 +1,11 @@ +**/.next +**/node_modules +**/style.ts +*.d.ts + +# axios http method function +packages/api/admin/src/libs/api/admin.ts +packages/api/client/src/libs/api/client.ts + +# page style +**/styles/page/*.ts \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ced1cdea..75a4fc262 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ # local env files **/.env*.local +**/.env # vercel **/.vercel @@ -34,3 +35,6 @@ **/*.tsbuildinfo **/next-env.d.ts **/node_modules + +# storybook +**/storybook-static diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..db793bed5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +**/.next +**/node_modules diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..35ced6057 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "jsxSingleQuote": true, + "trailingComma": "es5", + "printWidth": 80 +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..246e9c6e8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } +} diff --git a/README.md b/README.md index 440572ab6..e0e2acf36 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,20 @@ ## commands +> root `package.json` scripts에 `--filter` command를 포함하여 작성해두었습니다. + ```bash +$ git clone https://github.com/themoment-team/official-gsm-front.git +$ cd official-gsm-front +$ pnpm install + +# command of a specific package $ pnpm # example $ pnpm client dev ``` -`package.json`에 `--filter` command를 포함하여 작성해두었습니다. - ## directory structure ```bash @@ -23,9 +28,11 @@ $ pnpm client dev │ └── storybook │ │── packages +│ └── api │ └── common │ └── eslint-config-custom │ └── tsconfig +│ └── types │ └── ui ... ``` diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 000000000..e8d7c1f4d --- /dev/null +++ b/apps/admin/.env.example @@ -0,0 +1,3 @@ +CLIENT_API_URL = 'client-api-url' +ADMIN_API_URL = 'admin-api-url' +ADMIN_SIGNIN_URL = 'admin-signin-url' diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 766303457..0eedd0b14 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -2,10 +2,33 @@ const nextConfig = { reactStrictMode: true, swcMinify: true, - transpilePackages: ["ui", "common"], + transpilePackages: ['ui', 'common', 'api'], compiler: { emotion: true, }, + rewrites: async () => [ + { + source: '/api/client/:path*', + destination: `${process.env.CLIENT_API_URL}/api/:path*`, + }, + { + source: '/api/admin/:path*', + destination: `${process.env.ADMIN_API_URL}/api/:path*`, + }, + ], + redirects: async () => [ + { + source: '/api/signin', + destination: process.env.ADMIN_SIGNIN_URL, + permanent: true, + }, + ], + images: { + domains: [ + 'official-dev-bucket.s3.ap-northeast-2.amazonaws.com', + 'official-prod-bucket.s3.ap-northeast-2.amazonaws.com', + ], + }, }; module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json index 67d1858c6..0380aa2cd 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -9,20 +9,22 @@ "lint": "next lint" }, "dependencies": { - "@emotion/react": "^11.10.6", - "@emotion/styled": "^11.10.6", + "@hookform/resolvers": "^3.1.0", "@types/node": "20.1.4", - "@types/react": "18.2.6", "@types/react-dom": "18.2.4", + "api": "workspace:^", "common": "workspace:^", "eslint": "8.40.0", - "next": "13.4.2", - "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.44.3", + "react-toastify": "^9.1.3", + "types": "workspace:^", "typescript": "5.0.4", - "ui": "workspace:^" + "ui": "workspace:^", + "zod": "^3.21.4" }, "devDependencies": { + "@storybook/react": "^7.0.6", "eslint-config-custom": "workspace:^", "tsconfig": "workspace:^" } diff --git a/apps/admin/public/GSMLogo.png b/apps/admin/public/GSMLogo.png new file mode 100644 index 000000000..dd875407e Binary files /dev/null and b/apps/admin/public/GSMLogo.png differ diff --git a/apps/admin/public/blurGSMLogo.png b/apps/admin/public/blurGSMLogo.png new file mode 100644 index 000000000..b7e7820b3 Binary files /dev/null and b/apps/admin/public/blurGSMLogo.png differ diff --git a/apps/admin/public/models/approve.webm b/apps/admin/public/models/approve.webm new file mode 100644 index 000000000..c72dd70b3 Binary files /dev/null and b/apps/admin/public/models/approve.webm differ diff --git a/apps/admin/public/models/pending.webm b/apps/admin/public/models/pending.webm new file mode 100644 index 000000000..1c1715648 Binary files /dev/null and b/apps/admin/public/models/pending.webm differ diff --git a/apps/admin/public/models/school.webm b/apps/admin/public/models/school.webm new file mode 100644 index 000000000..2b45e81bf Binary files /dev/null and b/apps/admin/public/models/school.webm differ diff --git a/apps/admin/public/next.svg b/apps/admin/public/next.svg deleted file mode 100644 index 5174b28c5..000000000 --- a/apps/admin/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/admin/public/vercel.svg b/apps/admin/public/vercel.svg deleted file mode 100644 index d2f842227..000000000 --- a/apps/admin/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/admin/src/app/[category]/page.tsx b/apps/admin/src/app/[category]/page.tsx new file mode 100644 index 000000000..515f8d2cf --- /dev/null +++ b/apps/admin/src/app/[category]/page.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { redirect } from 'next/navigation'; + +import styled from '@emotion/styled'; + +import { + Category, + Header, + Banner, + PostList, + PostListHeader, + GalleryList, +} from 'admin/components'; + +import { useGetPostList } from 'api/client'; + +import { PaginationController } from 'ui'; + +const categoryParamsArray = ['', 'newsletter', 'gallery'] as const; + +const categoryQueryString = { + newsletter: 'FAMILY_NEWSLETTER', + gallery: 'EVENT_GALLERY', +} as const; + +const PAGE_SIZE = 6; + +type CategoryParamsType = keyof typeof categoryQueryString; + +interface ListPageProps { + params: { category: CategoryParamsType }; + searchParams: { pageNumber: string }; +} + +export default function ListPage({ + params: { category }, + searchParams, +}: ListPageProps) { + /** 1 ~ totalPages */ + const pageNumber = Number(searchParams.pageNumber ?? 1); + + const { data } = useGetPostList( + categoryQueryString[category], + pageNumber, + PAGE_SIZE + ); + + if (!categoryParamsArray.includes(category)) { + redirect('/'); + } + + if (Number.isNaN(pageNumber) || pageNumber < 1) { + redirect(`/${category}`); + } + + return ( + <> +
+ + + + + {category === 'gallery' ? ( + + ) : ( + + )} + {(data?.totalPages ?? 0) > 1 && ( + + )} + + + ); +} + +const ContentWrapper = styled.div` + display: flex; + align-items: center; + flex-direction: column; + margin-top: 2.5rem; + padding-bottom: 5rem; +`; + +// it's not working in client component + +// export function generateStaticParams() { +// return [{ category: "newsletter" }, { category: "gallery" }]; +// } + +// export const dynamicParams = false; diff --git a/apps/admin/src/app/api/file/route.ts b/apps/admin/src/app/api/file/route.ts new file mode 100644 index 000000000..7fb2cae35 --- /dev/null +++ b/apps/admin/src/app/api/file/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const fileUrl = searchParams.get('fileUrl'); + + if (!fileUrl) return NextResponse.error(); + + const res = await fetch(fileUrl); + const file = await res.blob(); + + return new NextResponse(file); +} diff --git a/apps/admin/src/app/auth/signin/layout.tsx b/apps/admin/src/app/auth/signin/layout.tsx new file mode 100644 index 000000000..81a022f58 --- /dev/null +++ b/apps/admin/src/app/auth/signin/layout.tsx @@ -0,0 +1,24 @@ +'use client'; + +import styled from '@emotion/styled'; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} + +const Background = styled.div` + width: 100%; + height: 100vh; + background-color: #0e0f10; + display: flex; + justify-content: center; + align-items: center; + @media (max-height: 26.5rem) { + height: auto; + align-items: baseline; + } +`; diff --git a/apps/admin/src/app/auth/signin/page.tsx b/apps/admin/src/app/auth/signin/page.tsx new file mode 100644 index 000000000..f056d9d61 --- /dev/null +++ b/apps/admin/src/app/auth/signin/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { GoogleLoginButton } from 'admin/components'; +import * as S from 'admin/styles/page/signin'; + +export default function SignInPage() { + return ( + + + + + + + + GSM 홈페이지 관리자용 페이지 + 입니다. +
+ 이용하시려면{' '} + 학교 전용 구글 아이디로 + 로그인해주세요. +
+ Google 로그인 +
+
+ ); +} diff --git a/apps/admin/src/app/auth/signin/warning/page.tsx b/apps/admin/src/app/auth/signin/warning/page.tsx new file mode 100644 index 000000000..2c064c150 --- /dev/null +++ b/apps/admin/src/app/auth/signin/warning/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { WarningIcon } from 'admin/assets'; +import { GoogleLoginButton } from 'admin/components'; +import * as S from 'admin/styles/page/signin'; + +export default function SignInWarningPage() { + return ( + + + + 학교 이메일로만 로그인 가능합니다. + 다시 로그인 + + + ); +} diff --git a/apps/admin/src/app/auth/signup/approve/page.tsx b/apps/admin/src/app/auth/signup/approve/page.tsx new file mode 100644 index 000000000..8c579e78e --- /dev/null +++ b/apps/admin/src/app/auth/signup/approve/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import styled from '@emotion/styled'; + +import { AuthTitle, AuthModel } from 'admin/components'; + +import { Button } from 'ui'; + +export default function IntroPage() { + const [isLoading, setIsLoading] = useState(false); + + return ( + <> + + 승인이 완료되었습니다. + + + + + ); +} + +const CustomLink = styled(Link)` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/apps/admin/src/app/auth/signup/intro/page.tsx b/apps/admin/src/app/auth/signup/intro/page.tsx new file mode 100644 index 000000000..9b88421dd --- /dev/null +++ b/apps/admin/src/app/auth/signup/intro/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import styled from '@emotion/styled'; + +import { AuthTitle, AuthModel, ToBackButton } from 'admin/components'; + +import { Button } from 'ui'; + +export default function IntroPage() { + const [isLoading, setIsLoading] = useState(false); + + const { back } = useRouter(); + + return ( + <> + + + 교사 회원 가입을 위해 이름과 +
+ 아이디 비밀번호를 입력해야 돼요. +
+ + + + ); +} + +const CustomLink = styled(Link)` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/apps/admin/src/app/auth/signup/layout.tsx b/apps/admin/src/app/auth/signup/layout.tsx new file mode 100644 index 000000000..228559ee9 --- /dev/null +++ b/apps/admin/src/app/auth/signup/layout.tsx @@ -0,0 +1,41 @@ +'use client'; + +import styled from '@emotion/styled'; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +const Background = styled.div` + width: 100%; + height: 100vh; + background-color: #f5f5f5; + display: flex; + justify-content: center; + align-items: center; + @media (max-height: 50rem) { + height: auto; + align-items: baseline; + } +`; + +const Content = styled.div` + width: 23.438rem; + height: 39.875rem; + border-radius: 1.25rem; + background-color: #ffffff; + box-shadow: 0 0.25rem 3.75rem rgba(0, 0, 0, 0.04); + padding: 1.5rem 1rem; + position: relative; + @media (max-height: 50rem) { + margin: 5rem 0; + } +`; diff --git a/apps/admin/src/app/auth/signup/page.tsx b/apps/admin/src/app/auth/signup/page.tsx new file mode 100644 index 000000000..ee3c17f83 --- /dev/null +++ b/apps/admin/src/app/auth/signup/page.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import styled from '@emotion/styled'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import type { SubmitHandler } from 'react-hook-form'; +import { toast } from 'react-toastify'; +import { z } from 'zod'; + +import { ToBackButton, Input, AuthTitle } from 'admin/components'; +import { usePreventHistoryPop } from 'admin/hooks'; + +import { useGetUserInfo, usePatchUserName } from 'api/admin'; + +import { Button } from 'ui'; + +const schema = z.object({ + name: z + .string() + .min(2, { message: '성함을 2글자 이상 입력해주세요.' }) + .max(5, { message: '성함을 5글자 이하로 입력해주세요.' }) + .regex(/^[가-힣]+$/, { message: '성함은 한글로만 입력해주세요.' }), +}); + +type FormType = z.infer; + +export default function SignupPage() { + const { replace } = useRouter(); + + const { mutate, isSuccess, isLoading, isError } = usePatchUserName(); + const { data: userInfo } = useGetUserInfo(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }); + + const onSubmit: SubmitHandler = ({ name }) => { + mutate(name); + }; + + if (userInfo?.userName) { + replace('/auth/signup/pending'); + } + + if (isSuccess) { + toast.success('가입 요청이 완료되었어요.'); + replace('/auth/signup/pending'); + } + + if (isError) { + toast.error('가입 요청이 실패되었어요.'); + } + + usePreventHistoryPop(); + + return ( + <> + replace('/auth/signin')} /> + + 우리 학교 선생님인지 +
+ 확인하기 위해 성함을 입력해주세요. +
+
+ + {errors.name && ( + {`* ${errors.name.message}`} + )} + +
+ + ); +} + +const ErrorMessage = styled.p` + font-weight: 400; + font-size: 0.75rem; + margin-top: 1rem; + color: #f93535; +`; diff --git a/apps/admin/src/app/auth/signup/pending/page.tsx b/apps/admin/src/app/auth/signup/pending/page.tsx new file mode 100644 index 000000000..21b099507 --- /dev/null +++ b/apps/admin/src/app/auth/signup/pending/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + + +import { AuthTitle, AuthModel, AuthDescription } from 'admin/components'; + +import { useGetUserInfo } from 'api/admin'; + +import { secondsToMs } from 'common'; + +import { Button } from 'ui'; + +export default function PendingPage() { + const { replace } = useRouter(); + const { data } = useGetUserInfo({ refetchInterval: secondsToMs(5) }); + + if (data?.role === 'ADMIN') { + replace('/auth/signup/approve'); + } + + return ( + <> + + 관리자에게 요청을 보냈어요. +
+ 요청이 승인될 때 까지 기다려주세요. +
+ 상황에 따라 시간이 걸릴 수 있어요. + + + + ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index ceb77daa4..ffe4c0433 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,8 +1,16 @@ -"use client"; +'use client'; -import { Inter } from "next/font/google"; +import React from 'react'; -const inter = Inter({ subsets: ["latin"] }); +import { ThemeProvider } from '@emotion/react'; + +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ToastContainer } from 'react-toastify'; + +import { GlobalStyle, theme } from 'common'; +import 'react-toastify/dist/ReactToastify.css'; + +import Providers from './providers'; export default function RootLayout({ children, @@ -10,12 +18,27 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + Admin - + + - {children} + + + + + + + {children} + + + ); } diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index be4a0cb2f..cd70aba60 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -1,7 +1,64 @@ -"use client"; +'use client'; -import { Button } from "ui"; +import { useRouter } from 'next/navigation'; -export default function Home() { - return ; +import styled from '@emotion/styled'; + +import { + Category, + Header, + Banner, + PostListHeader, + PostList, +} from 'admin/components'; + +import { useGetPostList } from 'api/client'; + +import { PaginationController } from 'ui'; + +interface HomeProps { + searchParams: { + pageNumber: string; + }; +} + +const PAGE_SIZE = 6; + +export default function Home({ searchParams }: HomeProps) { + const { replace } = useRouter(); + + /** 1 ~ totalPages */ + const pageNumber = Number(searchParams.pageNumber ?? 1); + + const { data } = useGetPostList('NOTICE', pageNumber, PAGE_SIZE); + + if (Number.isNaN(pageNumber) || pageNumber < 1) { + replace('/'); + } + + return ( + <> +
+ + + + + + {(data?.totalPages ?? 0) > 1 && ( + + )} + + + ); } + +const ContentWrapper = styled.div` + display: flex; + align-items: center; + flex-direction: column; + margin-top: 2.5rem; + padding-bottom: 5rem; +`; diff --git a/apps/admin/src/app/post/[postSeq]/page.tsx b/apps/admin/src/app/post/[postSeq]/page.tsx new file mode 100644 index 000000000..42929c399 --- /dev/null +++ b/apps/admin/src/app/post/[postSeq]/page.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { redirect } from 'next/navigation'; + +import styled from '@emotion/styled'; + +import { GalleryDetail, PostDetail, Header } from 'admin/components'; + +import { useGetPostDetail } from 'api/client'; + +interface DetailPageProps { + params: { postSeq: number }; +} + +export default function DetailPage({ params: { postSeq } }: DetailPageProps) { + const { data, isError } = useGetPostDetail(postSeq); + + if (isError) { + redirect('/'); + } + + return ( + +
+ {data?.category === 'EVENT_GALLERY' ? ( + + ) : ( + + )} + + ); +} + +const DetailPageWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 5rem; + padding-bottom: 5rem; +`; diff --git a/apps/admin/src/app/post/edit/[postSeq]/page.tsx b/apps/admin/src/app/post/edit/[postSeq]/page.tsx new file mode 100644 index 000000000..c22272d31 --- /dev/null +++ b/apps/admin/src/app/post/edit/[postSeq]/page.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { css } from '@emotion/react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { toast } from 'react-toastify'; + +import { + Input, + TextArea, + FileUploadLabel, + Header, + FormCategory, + FileCard, + FormTitleLength, + FormErrorMessage, + FormTitleLengthOver, +} from 'admin/components'; +import { usePreventClose } from 'admin/hooks'; +import { categoryPath, fileExtension, postFormSchema } from 'admin/shared'; +import * as S from 'admin/styles/page/write'; +import type { PostFormType } from 'admin/types'; + +import { usePatchPost } from 'api/admin'; +import { useGetPostDetail } from 'api/client'; + +import { Button } from 'ui'; + +import type { FileInfoType, CategoryQueryStringType } from 'types'; + +interface EditPageProps { + params: { postSeq: number }; +} + +export default function EditPage({ params: { postSeq } }: EditPageProps) { + const [category, setCategory] = useState('NOTICE'); + const [files, setFiles] = useState([]); + const [prevFiles, setPrevFiles] = useState(); + const [deleteFileUrl, setDeleteFileUrl] = useState([]); + const fileInput = useRef(null); + + usePreventClose(); + + const { replace, back } = useRouter(); + const { mutate, isSuccess, isError } = usePatchPost(postSeq); + const { data } = useGetPostDetail(postSeq); + + const isGallery = category === 'EVENT_GALLERY'; + const gallerySubmitDisabled = isGallery && files.length === 0; + + const { + register, + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(postFormSchema), + defaultValues: { + title: data?.postTitle, + content: data?.postContent, + }, + }); + + useEffect(() => { + reset({ + title: data?.postTitle, + content: data?.postContent, + }); + }, [data?.postContent, data?.postTitle, reset]); + + useEffect(() => { + setCategory(data?.category ?? 'NOTICE'); + setPrevFiles(data?.fileInfo); + }, [data]); + + const handleCancel = (fileName: string, fileUrl?: string) => { + setFiles((prevState) => prevState.filter((file) => file.name !== fileName)); + setPrevFiles((prevState) => + prevState?.filter((file) => file.fileName !== fileName) + ); + fileUrl && setDeleteFileUrl((prevArray) => [...prevArray, fileUrl]); + }; + + const onSubmit: SubmitHandler = (data) => { + const content = { + postTitle: data.title, + postContent: data.content, + category, + deleteFileUrl, + }; + + const formData = new FormData(); + + formData.append( + 'content', + new Blob([JSON.stringify(content)], { type: 'application/json' }) + ); + + files.forEach((file) => formData.append('file', file)); + + mutate(formData); + }; + + if (isSuccess) { + toast.success('게시물 수정이 완료되었어요.'); + replace(categoryPath[category]); + } + + if (isError) { + toast.error('게시물 수정이 실패되었어요.'); + } + + const postFile = () => { + setFiles( + fileInput.current?.files?.length + ? [...files, ...fileInput.current.files].filter( + (element, index, arr) => + index === arr.findIndex((files) => files.name === element.name) + ) + : files + ); + }; + + return ( + <> +
+ + 게시물 수정 + +
+ 카테고리 + +
+
+ 제목 (필수) +
+ setInput(e.target.value)} + maxLength={60} + /> + +
+ + {errors.title && ( + {`* ${errors.title.message}`} + )} +
+
+ 내용 +