├── src ├── react-app-env.d.ts ├── util │ ├── dayOfWeek.ts │ ├── addWeek.ts │ ├── addMonth.ts │ ├── formatDay.ts │ ├── getThisWeek.ts │ ├── createSelectTimes.ts │ ├── HoursAday.ts │ ├── checkIsThisWeek.ts │ └── getCalendar.ts ├── App.tsx ├── index.tsx ├── store │ ├── index.ts │ └── modules │ │ ├── schedule.ts │ │ └── calendar.ts ├── index.css ├── components │ ├── SideCalendarTitle.tsx │ ├── SideCalendar.tsx │ ├── AddScheduleButton.tsx │ ├── Header.tsx │ ├── ScheduleCalendar.tsx │ └── AddScheduleModal.tsx └── pages │ └── Calendar.tsx ├── docs ├── 레이아웃.png ├── 반응형.webp ├── 날짜이동.webp ├── 사이드메뉴.webp ├── 삭제모달.webp ├── 월별이동.webp ├── 일정만들기.webp ├── 일정삭제.webp ├── 일정생성.webp ├── 일정유지.webp ├── 종료시간.webp └── 주별이동.webp ├── public ├── favicon.ico ├── menu.svg ├── left.svg ├── right.svg ├── index.html └── calendar.svg ├── postcss.config.js ├── tailwind.config.js ├── .prettierrc ├── .gitignore ├── index.d.ts ├── tsconfig.json ├── .eslintrc ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/util/dayOfWeek.ts: -------------------------------------------------------------------------------- 1 | export const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'] 2 | -------------------------------------------------------------------------------- /docs/레이아웃.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/레이아웃.png -------------------------------------------------------------------------------- /docs/반응형.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/반응형.webp -------------------------------------------------------------------------------- /docs/날짜이동.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/날짜이동.webp -------------------------------------------------------------------------------- /docs/사이드메뉴.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/사이드메뉴.webp -------------------------------------------------------------------------------- /docs/삭제모달.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/삭제모달.webp -------------------------------------------------------------------------------- /docs/월별이동.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/월별이동.webp -------------------------------------------------------------------------------- /docs/일정만들기.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/일정만들기.webp -------------------------------------------------------------------------------- /docs/일정삭제.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/일정삭제.webp -------------------------------------------------------------------------------- /docs/일정생성.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/일정생성.webp -------------------------------------------------------------------------------- /docs/일정유지.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/일정유지.webp -------------------------------------------------------------------------------- /docs/종료시간.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/종료시간.webp -------------------------------------------------------------------------------- /docs/주별이동.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/docs/주별이동.webp -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MiryangJung/google-calendar-weekly-clone/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | } 8 | -------------------------------------------------------------------------------- /public/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/util/addWeek.ts: -------------------------------------------------------------------------------- 1 | export default function addWeek(date: string, week: number): string { 2 | const newDate = new Date(date) 3 | newDate.setDate(newDate.getDate() + week * 7) 4 | return newDate.toString() 5 | } 6 | -------------------------------------------------------------------------------- /public/left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "bracketSpacing": true, 5 | "tabWidth": 2, 6 | "semi": false, 7 | "arrowParens": "avoid", 8 | "endOfLine": "lf", 9 | "printWidth": 100 10 | } -------------------------------------------------------------------------------- /src/util/addMonth.ts: -------------------------------------------------------------------------------- 1 | export default function addMonth(date: string, month: number): string { 2 | const newDate = new Date(date) 3 | newDate.setDate(1) 4 | newDate.setMonth(newDate.getMonth() + month) 5 | return newDate.toString() 6 | } 7 | -------------------------------------------------------------------------------- /src/util/formatDay.ts: -------------------------------------------------------------------------------- 1 | export default function formatDay(day: Date): string { 2 | const month = day.getMonth() + 1 3 | const date = day.getDate() 4 | return `${day.getFullYear()}-${month < 10 ? `0${month}` : month}-${date < 10 ? `0${date}` : date}` 5 | } 6 | -------------------------------------------------------------------------------- /src/util/getThisWeek.ts: -------------------------------------------------------------------------------- 1 | import { tDays } from '../../index' 2 | 3 | export default function getThisWeek(days: tDays[]): tDays[] { 4 | const isThisWeekAndSunday = (element: tDays) => element.isThisWeek && element.dayOfWeek === 0 5 | const thisWeekAndSundayIndex = days.findIndex(isThisWeekAndSunday) 6 | return days.slice(thisWeekAndSundayIndex, thisWeekAndSundayIndex + 7) 7 | } 8 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter, Route, Routes } from 'react-router-dom' 3 | import Calendar from './pages/Calendar' 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | } /> 10 | 11 | 12 | ) 13 | } 14 | 15 | export default App 16 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | import { Provider } from 'react-redux' 6 | import { store } from './store' 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea 26 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import calendarReducer from './modules/calendar' 3 | import scheduleReducer from './modules/schedule' 4 | 5 | export const store = configureStore({ 6 | reducer: { 7 | calendar: calendarReducer, 8 | schedule: scheduleReducer, 9 | }, 10 | devTools: process.env.NODE_ENV !== 'production', 11 | }) 12 | 13 | export type RootState = ReturnType 14 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Google Calendar Clone 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/util/createSelectTimes.ts: -------------------------------------------------------------------------------- 1 | import { hours24 } from './HoursAday' 2 | 3 | export default function createSelectTimes(): Array<{ hour: number; minute: string; text: string }> { 4 | const minutes = ['00', '15', '30', '45'] 5 | 6 | const times: Array<{ hour: number; minute: string; text: string }> = [] 7 | hours24.forEach(h => { 8 | minutes.forEach(m => { 9 | times.push({ 10 | hour: h.hour, 11 | minute: m, 12 | text: `${h.text}:${m}`, 13 | }) 14 | }) 15 | }) 16 | return times 17 | } 18 | -------------------------------------------------------------------------------- /src/util/HoursAday.ts: -------------------------------------------------------------------------------- 1 | import { tHours } from '../../index' 2 | 3 | function create24HoursArray() { 4 | const hours: Array = [] 5 | for (let i = 0; i < 12; i++) { 6 | const hour = i 7 | const item = { text: `오전 ${i === 0 ? 12 : i}`, hour } 8 | hours.push(item) 9 | } 10 | for (let i = 0; i < 12; i++) { 11 | const hour = i === 0 ? 12 : i + 12 12 | const item = { text: `오후 ${i === 0 ? 12 : i}`, hour } 13 | hours.push(item) 14 | } 15 | return hours 16 | } 17 | 18 | export const hours24 = create24HoursArray() 19 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type tDays = { 2 | date: number 3 | dayOfWeek: number 4 | isToday: boolean 5 | isSelected: boolean 6 | isThisWeek: boolean 7 | isThisMonth: boolean 8 | day: string 9 | } 10 | 11 | export type tHours = { 12 | text: string 13 | hour: number 14 | } 15 | 16 | export type tTime = { hour: number; minute: number } 17 | 18 | export type tRangeColor = 'red' | 'orange' | 'green' | 'blue' | 'brown' | 'pink' 19 | export type tScheduleDetail = { start: tTime; end: tTime; color: tRangeColor; title: string } 20 | 21 | export type tSchedule = { [key: string]: Array } 22 | -------------------------------------------------------------------------------- /src/util/checkIsThisWeek.ts: -------------------------------------------------------------------------------- 1 | export default function checkIsThisWeek(day: Date, current: Date): boolean { 2 | const currentDate = current.getDate() 3 | const currentYear = current.getFullYear() 4 | const currentMonth = current.getMonth() 5 | const currentDay = current.getDay() 6 | const firstDayOfThisWeek = new Date(currentYear, currentMonth, currentDate - currentDay).getTime() 7 | const lastDayOfThisWeek = new Date( 8 | currentYear, 9 | currentMonth, 10 | currentDate - currentDay + 6 11 | ).getTime() 12 | return firstDayOfThisWeek <= day.getTime() && day.getTime() <= lastDayOfThisWeek 13 | } 14 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, body { 6 | min-width: 400px; 7 | scroll-behavior: smooth; 8 | height: 100vh; 9 | background: white; 10 | } 11 | 12 | #root{ 13 | width: 100%; 14 | height: 100vh; 15 | } 16 | 17 | select{ 18 | -webkit-appearance: none; 19 | } 20 | 21 | input[type="date"] { 22 | position: relative; 23 | } 24 | 25 | input[type="date"]::-webkit-calendar-picker-indicator { 26 | position: absolute; 27 | top: 0; 28 | left: 0; 29 | right: 0; 30 | bottom: 0; 31 | width: auto; 32 | height: auto; 33 | color: transparent; 34 | background: transparent; 35 | } 36 | 37 | input[type="date"]::-webkit-inner-spin-button { 38 | z-index: 1; 39 | } 40 | 41 | input[type="date"]::-webkit-clear-button { 42 | z-index: 1; 43 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["prettier", "@typescript-eslint"], 4 | "extends": [ 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react/recommended", 7 | "plugin:react-hooks/recommended", 8 | "prettier" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 2018, 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "prettier/prettier": ["error", { "endOfLine": "auto" }], 16 | "@typescript-eslint/explicit-module-boundary-types": "off", 17 | "react/react-in-jsx-scope": "off", 18 | "import/no-unresolved": "off", 19 | "linebreak-style": "off" 20 | }, 21 | "settings": { 22 | "react": { 23 | "version": "detect" 24 | }, 25 | "import/resolver": { 26 | "node": { 27 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/components/SideCalendarTitle.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux' 2 | import { lastMonth, nextMonth } from '../store/modules/calendar' 3 | 4 | export default function SideCalendarTitle({ year, month }: { year: number; month: number }) { 5 | const dispatch = useDispatch() 6 | return ( 7 |
8 | 9 | {year}년 {month}월 10 | 11 |
12 | logo dispatch(lastMonth())} 19 | /> 20 | logo dispatch(nextMonth())} 27 | /> 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /public/calendar.svg: -------------------------------------------------------------------------------- 1 | Google Calendar -------------------------------------------------------------------------------- /src/store/modules/schedule.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { tSchedule, tScheduleDetail } from '../../../index' 3 | import { RootState } from '../index' 4 | 5 | const initialState: tSchedule = { 6 | '2022-01-01': [ 7 | { 8 | start: { hour: 1, minute: 20 }, 9 | end: { hour: 1, minute: 40 }, 10 | color: 'pink', 11 | title: '코딩하기', 12 | }, 13 | ], 14 | } 15 | 16 | export const scheduleSlice = createSlice({ 17 | name: 'schedule', 18 | initialState, 19 | reducers: { 20 | addSchedule: (state, action: PayloadAction<{ date: string; data: tScheduleDetail }>) => { 21 | if (!state[action.payload.date]) { 22 | state[action.payload.date] = [] 23 | } 24 | state[action.payload.date] = [...state[action.payload.date], action.payload.data] 25 | }, 26 | removeSchedule: (state, action: PayloadAction<{ date: string; index: number }>) => { 27 | delete state[action.payload.date][action.payload.index] 28 | }, 29 | }, 30 | }) 31 | 32 | export const { addSchedule, removeSchedule } = scheduleSlice.actions 33 | export const schedules = (state: RootState) => state.schedule 34 | 35 | export default scheduleSlice.reducer 36 | -------------------------------------------------------------------------------- /src/components/SideCalendar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { tDays } from '../../index' 3 | import { useDispatch } from 'react-redux' 4 | import { selectDay } from '../store/modules/calendar' 5 | import { dayOfWeek } from '../util/dayOfWeek' 6 | 7 | export default function SideCalendar({ days }: { days: tDays[] }) { 8 | const dispatch = useDispatch() 9 | return ( 10 | 11 | 12 | 13 | {dayOfWeek.map((day, i) => ( 14 | 17 | ))} 18 | 19 | 20 | 21 | {days.map((day, index) => ( 22 | 23 | {day.dayOfWeek === 0 && ( 24 | 25 | {days.slice(index, index + 7).map(d => ( 26 | 36 | ))} 37 | 38 | )} 39 | 40 | ))} 41 | 42 |
15 | {day} 16 |
dispatch(selectDay(new Date(d.day).toString()))} 29 | className={`px-2 py-2 text-xs text-center cursor-pointer 30 | ${d.isThisMonth ? 'text-stone-900' : 'text-stone-400'} 31 | ${d.isSelected && 'bg-blue-100 text-blue-600 rounded-full'} 32 | ${d.isToday && 'bg-blue-500 text-white rounded-full'}`} 33 | > 34 | {d.date} 35 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/AddScheduleButton.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react' 2 | 3 | export default function AddScheduleButton({ 4 | isSideCalendar, 5 | isOpenModal, 6 | setIsOpenModal, 7 | }: { 8 | isSideCalendar: boolean 9 | isOpenModal: boolean 10 | setIsOpenModal: Dispatch> 11 | }) { 12 | return ( 13 |
17 | 34 | 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-calendar-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.7.1", 7 | "@testing-library/jest-dom": "^5.16.1", 8 | "@testing-library/react": "^12.1.2", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/jest": "^27.0.3", 11 | "@types/node": "^16.11.17", 12 | "@types/react": "^17.0.38", 13 | "@types/react-dom": "^17.0.11", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-redux": "^7.2.6", 17 | "react-router-dom": "^6.2.1", 18 | "react-scripts": "5.0.0", 19 | "redux": "^4.1.2", 20 | "typescript": "^4.5.4", 21 | "web-vitals": "^2.1.2" 22 | }, 23 | "devDependencies": { 24 | "@types/react-redux": "^7.1.21", 25 | "@typescript-eslint/eslint-plugin": "^5.0.0", 26 | "@typescript-eslint/parser": "^5.0.0", 27 | "autoprefixer": "^10.4.0", 28 | "eslint-config-airbnb": "^18.2.1", 29 | "eslint-config-prettier": "^8.3.0", 30 | "eslint-plugin-import": "^2.22.1", 31 | "eslint-plugin-prettier": "^4.0.0", 32 | "eslint-plugin-react": "^7.26.1", 33 | "eslint-plugin-react-hooks": "^4.2.0", 34 | "postcss": "^8.4.5", 35 | "prettier": "^2.4.1", 36 | "tailwindcss": "^3.0.7" 37 | }, 38 | "scripts": { 39 | "start": "react-scripts start", 40 | "build": "react-scripts build", 41 | "test": "react-scripts test", 42 | "eject": "react-scripts eject" 43 | }, 44 | "eslintConfig": { 45 | "extends": [ 46 | "react-app", 47 | "react-app/jest" 48 | ] 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.2%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux' 2 | import { lastWeek, nextWeek, selectDay } from '../store/modules/calendar' 3 | import { Dispatch, SetStateAction } from 'react' 4 | 5 | export default function Header({ 6 | year, 7 | month, 8 | isSideCalendar, 9 | setIsSideCalendar, 10 | }: { 11 | year: number 12 | month: number 13 | isSideCalendar: boolean 14 | setIsSideCalendar: Dispatch> 15 | }) { 16 | const dispatch = useDispatch() 17 | return ( 18 |
19 |
20 |
setIsSideCalendar(!isSideCalendar)} 23 | > 24 | menu 25 |
26 |
27 | logo 28 |

캘린더

29 |
30 |
31 |
32 | 38 | logo dispatch(lastWeek())} 45 | /> 46 | logo dispatch(nextWeek())} 53 | /> 54 | 55 | {year}년 {month}월 56 | 57 |
58 | 59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import Header from '../components/Header' 2 | import SideCalendar from '../components/SideCalendar' 3 | import SideCalendarTitle from '../components/SideCalendarTitle' 4 | import { useSelector } from 'react-redux' 5 | import { currentCalendar } from '../store/modules/calendar' 6 | import ScheduleCalendar from '../components/ScheduleCalendar' 7 | import getThisWeek from '../util/getThisWeek' 8 | import { useState } from 'react' 9 | import AddScheduleButton from '../components/AddScheduleButton' 10 | import AddScheduleModal from '../components/AddScheduleModal' 11 | import formatDay from '../util/formatDay' 12 | 13 | export default function Calendar() { 14 | const { year, month, days } = useSelector(currentCalendar) 15 | const [isSideCalendar, setIsSideCalendar] = useState(true) 16 | const [isOpenModal, setIsOpenModal] = useState(false) 17 | const [isDeleteOpen, setIsDeleteOpen] = useState(false) 18 | const [modalDate, setModalDate] = useState(formatDay(new Date())) 19 | const [timeIndex, setTimeIndex] = useState(0) 20 | 21 | return ( 22 | <> 23 |
29 |
30 | 35 |
36 | 37 | 38 |
39 |
40 | 48 |
49 | 55 |
56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/util/getCalendar.ts: -------------------------------------------------------------------------------- 1 | import { tDays } from '../../index' 2 | import checkIsThisWeek from './checkIsThisWeek' 3 | import formatDay from './formatDay' 4 | 5 | export default function getCalendar({ select, current }: { select: Date; current: Date }): { 6 | days: tDays[] 7 | month: number 8 | year: number 9 | } { 10 | const todayDate = new Date().getDate() 11 | const todayMonth = new Date().getMonth() 12 | const selectDate = select.getDate() 13 | const selectMonth = select.getMonth() 14 | const currentYear = current.getFullYear() 15 | const currentMonth = current.getMonth() 16 | const firstDay = new Date(currentYear, currentMonth, 1) 17 | const lastDay = new Date(currentYear, currentMonth + 1, 0) 18 | const days: tDays[] = [] 19 | let dayOfWeek = firstDay.getDay() 20 | let dayNumber = 1 21 | 22 | if (dayOfWeek !== 0) { 23 | const lastDayOfLastMonth = new Date(currentYear, currentMonth, 0) 24 | for (let i = dayOfWeek - 1; i >= 0; i--) { 25 | const y = lastDayOfLastMonth.getFullYear() 26 | const d = lastDayOfLastMonth.getDate() - i 27 | const m = lastDayOfLastMonth.getMonth() 28 | const day = new Date(y, m, d) 29 | days.push({ 30 | date: d, 31 | dayOfWeek: lastDayOfLastMonth.getDay() - i, 32 | isToday: d === todayDate && m === todayMonth, 33 | isSelected: d === selectDate && m === selectMonth, 34 | isThisWeek: checkIsThisWeek(day, current), 35 | isThisMonth: false, 36 | day: formatDay(day), 37 | }) 38 | } 39 | } 40 | 41 | while (dayNumber <= lastDay.getDate()) { 42 | const day = new Date(currentYear, currentMonth, dayNumber) 43 | days.push({ 44 | date: dayNumber, 45 | dayOfWeek, 46 | isToday: dayNumber === todayDate && currentMonth === todayMonth, 47 | isSelected: dayNumber === selectDate && currentMonth === selectMonth, 48 | isThisWeek: checkIsThisWeek(day, current), 49 | isThisMonth: true, 50 | day: formatDay(day), 51 | }) 52 | dayNumber++ 53 | dayOfWeek = (dayOfWeek + 1) % 7 54 | } 55 | 56 | if (dayOfWeek !== 0) { 57 | const nextDayOfNextMonth = new Date(currentYear, currentMonth + 1, 1) 58 | for (let i = 0; i < 7 - dayOfWeek; i++) { 59 | const y = nextDayOfNextMonth.getFullYear() 60 | const d = nextDayOfNextMonth.getDate() 61 | const m = nextDayOfNextMonth.getMonth() 62 | const day = new Date(y, m, d) 63 | days.push({ 64 | date: d + i, 65 | dayOfWeek: dayOfWeek + i, 66 | isToday: d === todayDate && m === todayMonth, 67 | isSelected: d === selectDate && m === selectMonth, 68 | isThisWeek: checkIsThisWeek(day, current), 69 | isThisMonth: false, 70 | day: formatDay(day), 71 | }) 72 | } 73 | } 74 | 75 | return { days, month: currentMonth + 1, year: currentYear } 76 | } 77 | -------------------------------------------------------------------------------- /src/store/modules/calendar.ts: -------------------------------------------------------------------------------- 1 | import { tDays } from '../../../index' 2 | import getCalendar from '../../util/getCalendar' 3 | import addWeek from '../../util/addWeek' 4 | import addMonth from '../../util/addMonth' 5 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 6 | import { RootState } from '../index' 7 | 8 | type tCurrent = { day: string; days: tDays[]; year: number; month: number } 9 | type tCalendar = { 10 | select: string 11 | current: tCurrent 12 | } 13 | 14 | const today = new Date() 15 | const initCalendar = getCalendar({ select: today, current: today }) 16 | 17 | const initialState: tCalendar = { 18 | select: today.toString(), 19 | current: { 20 | day: new Date(today).toString(), 21 | days: initCalendar.days, 22 | year: initCalendar.year, 23 | month: initCalendar.month, 24 | }, 25 | } 26 | 27 | const createNewDate = ({ selectDate, changeDate }: { selectDate: string; changeDate: string }) => { 28 | return getCalendar({ 29 | select: new Date(selectDate), 30 | current: new Date(changeDate), 31 | }) 32 | } 33 | 34 | export const calendarSlice = createSlice({ 35 | name: 'calendar', 36 | initialState, 37 | reducers: { 38 | nextWeek: state => { 39 | const addWeekDate = addWeek(state.current.day, 1) 40 | const newDate = createNewDate({ selectDate: state.select, changeDate: addWeekDate }) 41 | state.current = { 42 | day: addWeekDate, 43 | ...newDate, 44 | } 45 | }, 46 | nextMonth: state => { 47 | const addMonthDate = addMonth(state.current.day, 1) 48 | const newDate = createNewDate({ selectDate: state.select, changeDate: addMonthDate }) 49 | state.current = { 50 | day: addMonthDate, 51 | ...newDate, 52 | } 53 | }, 54 | lastWeek: state => { 55 | const backWeekDate = addWeek(state.current.day, -1) 56 | const newDate = createNewDate({ selectDate: state.select, changeDate: backWeekDate }) 57 | state.current = { 58 | day: backWeekDate, 59 | ...newDate, 60 | } 61 | }, 62 | lastMonth: state => { 63 | const backMonthDate = addMonth(state.current.day, -1) 64 | const newDate = createNewDate({ selectDate: state.select, changeDate: backMonthDate }) 65 | state.current = { 66 | day: backMonthDate, 67 | ...newDate, 68 | } 69 | }, 70 | selectDay: (state, action: PayloadAction) => { 71 | state.select = action.payload 72 | const selectDate = new Date(action.payload).toString() 73 | const newDate = createNewDate({ selectDate: selectDate, changeDate: selectDate }) 74 | state.current = { 75 | day: selectDate, 76 | ...newDate, 77 | } 78 | }, 79 | }, 80 | }) 81 | 82 | export const { nextWeek, nextMonth, lastWeek, lastMonth, selectDay } = calendarSlice.actions 83 | export const currentCalendar = (state: RootState) => state.calendar.current 84 | 85 | export default calendarSlice.reducer 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google Calendar Clone 2 | > 구글 캘린더 주별 화면을 클론하여 사용할 수 있도록 함 3 | 4 | ## 실행 5 | 6 | ```shell 7 | yarn install 8 | yarn start 9 | ``` 10 | 11 | `localhost:3000` 접속 12 | 13 | ## 사용 14 | 15 | - React 16 | - CRA 17 | - tailwind css 18 | - typescript 19 | - redux 20 | 21 | ## 목차 22 | 23 | - [기능](#기능) 24 | - [구현](#구현) 25 | - [트러블 슈팅](#트러블-슈팅) 26 | 27 | --- 28 | 29 | ## 기능 30 | 31 | ### 반응형 32 | 33 | 화면 사이즈 및 사이드바 여부에 따라 달력의 크기도 변화 34 | 35 | | 반응형 | 사이드 메뉴 | 36 | |---------------------|-----------------------| 37 | | ![](./docs/반응형.webp) | ![](./docs/사이드메뉴.webp) | 38 | 39 | ### 달력 이동 40 | 41 | | 월별 이동 | 주별 이동 | 오늘 날짜로 이동 및 날짜 선택 시 이동 | 42 | |----------------------|----------------------|------------------------| 43 | | ![](./docs/월별이동.webp) | ![](./docs/주별이동.webp) | ![](./docs/날짜이동.webp) | 44 | 45 | ### 일정 만들기 46 | 47 | | 클릭한 일정칸에 맞게 날짜와 시간 자동 반영된 모달 | 종료 시간을 시작 시간을 기준으로 변경 | 48 | |------------------------------|-----------------------| 49 | | ![](./docs/일정만들기.webp) | ![](./docs/종료시간.webp) | 50 | 51 | | 일정 생성 | 일정 유지 | 52 | |----------------------|----------------------| 53 | | ![](./docs/일정생성.webp) | ![](./docs/일정유지.webp) | 54 | 55 | ### 일정 삭제 56 | 57 | | 커서 위치에 따른 삭제 모달 | 일정 삭제 | 58 | |----------------------|----------------------| 59 | | ![](./docs/삭제모달.webp) | ![](./docs/일정삭제.webp) | 60 | 61 | --- 62 | 63 | ## 구현 64 | 65 | ### 레이아웃 66 | 67 | ![](./docs/레이아웃.png) 68 | 69 | ### 파일별 역할 70 | 71 | __src/util__ 72 | 73 | | 함수 | 역할 | 74 | |---------------------|-------------------------------| 75 | | `addMonth` | 1달 더하기 | 76 | | `addWeek` | 1주 더하기 | 77 | | `checkIsThisWeek` | 날짜가 포커스된 날짜의 주차에 포함되는지 체크 | 78 | | `createSelectTimes` | 일정 생성 모달의 시간 선택 option 리스트 생성 | 79 | | `dayOfWeek` | 일~월까지의 한글 요일 배열 | 80 | | `formatDay` | yyyy-mm-dd 형식으로 변환 | 81 | | `getCalendar` | 포커스된 월의 데이터 생성 | 82 | | `getThisWeek` | isThisWeek 가 표시된 1주 추출 | 83 | | `hours24` | 24시간 리스트 생성 | 84 | 85 | __src/components__ 86 | 87 | | 함수 | 역할 | 88 | |---------------------|------------------| 89 | | `AddScheduleButton` | 왼쪽 위의 일정 생성 버튼 | 90 | | `AddScheduleModal` | 일정 생성 모달 | 91 | | `Header` | 헤더 | 92 | | `ScheduleCalendar` | 주별 달력 | 93 | | `SideCalendar` | 사이드바 월별 달력 | 94 | | `SideCalendarTitle` | 사이드바 월별 달력의 헤더영역 | 95 | 96 | __src/store/module__ 97 | 98 | | 함수 | 역할 | 99 | |------------|---------------| 100 | | `calendar` | 포커스된 날짜 기준 달력 | 101 | | `schedule` | 일정 | 102 | 103 | ### 달력 이동 104 | 105 | 1. `redux` 를 사용 106 | 2. 달력 이동 시 `nextWeek` 등의 리듀서 실행 107 | 3. 변경된 데이터가 관련 컴포넌트에 자동 반영 108 | 109 | ``` 110 | { 111 | select: 월 달력에서 날짜 선택 시, 112 | current: { 월별 이동, 주별 이동 등 포커스 이동에 따라 113 | days: 포커스된 월 달력 리스트, 114 | day: 현재 포커스된 날짜, 115 | year: 포커스된 년도, 116 | month: 포커스된 월, 117 | }, 118 | } 119 | ``` 120 | 121 | - `day, year, month` 를 저장하는 이유 122 | - 달력 타이틀에 쉽게 반영하기 위해 123 | 124 | ### 일정 생성 125 | 126 | 1. `redux` 를 사용 127 | 2. 날짜를 key로 value를 배열로 사용하는 데이터 형태 사용 128 | 3. 시작 시간, 종료 시간 저장 129 | 130 | ``` 131 | '2022-01-01': [ 132 | { 133 | start: { hour: 1, minute: 20 }, 134 | end: { hour: 1, minute: 40 }, 135 | color: 'pink', 136 | title: '코딩하기', 137 | }, 138 | ], 139 | ``` 140 | 141 | - `hour` 과 `minute` 을 따로 저장하는 이유 142 | - 일정 컴포넌트를 생성할 때 높이를 쉽게 지정하기 위해 143 | - `높이 = (end.hour - start.hour) * (한 칸 높이) - start.minute + end.minute` 144 | 145 | ### 일정 삭제 146 | 147 | - 해당 일정의 key(날짜)와 배열 index 값을 사용해서 삭제 148 | 149 | ``` 150 | delete 일정[날짜][순서(index)] 151 | ``` 152 | 153 | ### 일정 모달 시간 제어 154 | 155 | - 시작 시간 <= 종료 시간이 되도록 156 | - 가능한 시간의 수로 배열 생성 157 | - 0~24 시 158 | - 00, 15, 30, 45 분 159 | - 선택한 시작 시간이 종료 시간의 Index보다 클 경우 160 | - 종료 시간의 Index을 변경 161 | 162 | ``` 163 | if (endSelectTimeIndex < startSelectTimeIndex) { 164 | endTimeChange(...) 165 | } 166 | ``` 167 | 168 | ### 일정 생성 모달 보여주기/숨기기 169 | 170 | 1. 부모 컴포넌트에서 `useState` 로 state 생성 171 | 2. 일정 모달 트리거가 있는 자식 요소에 `SetStateAction` 전달 172 | 3. 자식 요소에서 `SetStateAction` 로 제어 173 | 174 | - 부모 요소에서 `state` 생성한 이유 175 | - 왼쪽 위의 일정 생성 버튼 트리거도 같은 모달컴포넌트를 사용하기 위해 176 | 177 | ### 달력 클릭시 일정 모달에 날짜, 시간 반영하기 178 | 179 | - 모달의 `Input` 값을 `state` 로 적용 180 | - 달력의 날짜 클릭시 모달 `Input state` 를 변경 181 | 182 | --- 183 | 184 | ## 트러블 슈팅 185 | 186 | ### redux state에 Date가 저장되지 않음 187 | 188 | __문제__ 189 | - redux state에 `Date` 타입의 값을 넣었더니 에러 190 | 191 | __해결__ 192 | - Json에는 `Date` 형식이 없다는 것을 학습 193 | - `string` 으로 변환하여 저장 194 | 195 | ### 월 이동 시 두 달이 넘어가는 문제 196 | 197 | __문제__ 198 | - 예) 1월 31일이 선택된 상태에서 다음 월(2월)로 이동 시 3월로 넘어감 199 | - `new Date(2021, 1, 31)` === `Mar 03 2021` 200 | 201 | __수정__ 202 | - 월 이동 시 포커스를 무조건 `1일` 로 설정 203 | 204 | __해결 방법으로 생각 중__ 205 | - 이동할 달의 마지막 날을 알아낸 뒤, 206 | - 현재 포커스된 날과 비교하여 제어 207 | 208 | ### 날짜 지정이 안되는 문제 209 | 210 | __문제__ 211 | - `2021-1-1` 와 같은 형태는 `input type=date` 에 값으로 넣을 수 없음 212 | 213 | __해결__ 214 | - `2021-01-01` 과 같은 형태로 변형 215 | - `month < 10 ? `0${month}` : month` 216 | 217 | ### 일정 컴포넌트 높이 지정 문제 218 | 219 | __문제__ 220 | - 시간 시간과 종료 시간이 같을 때 일정 컴포넌트 생성 시 높이가 생기지 않음 221 | - 높이 : `(end.hour - start.hour) * (한 칸 높이)` 222 | 223 | __해결__ 224 | - `if (h < 20) h = 20` 추가 225 | - 높이가 20보다 작을 경우 무조건 20의 높이 지정 226 | 227 | ### 삭제 모달 띄울때 위치 문제 228 | 229 | __문제__ 230 | - 일정 컴포넌트의 2/3 지점을 삭제 모달 위치로 지정 231 | - 높이가 길어 스크롤을 해야하는 일정의 경우 가려져서 모달이 안보임 232 | 233 | __해결__ 234 | - 클릭했을 때 마우스 값을 가져와서 위치로 지정 235 | 236 | --- 237 | 238 | ## 학습한 내용 239 | 240 | ### tailwind css 241 | - 유틸리티 퍼스트(Utility-first)를 지향하는 CSS 프레임워크 242 | - 유틸리티 퍼스트? 243 | - 스타일을 미리 정의해두고 조합하는 식의 효용성을 가진다 244 | - `flex, pt-4, text-center` 등 유틸리티 클래스 사용 245 | - 일관된 스타일을 구현하기 쉬움 246 | - 작은 Element를 만들 때에도 Component를 생성해야하는 Styled에 비해 편하다. 247 | -------------------------------------------------------------------------------- /src/components/ScheduleCalendar.tsx: -------------------------------------------------------------------------------- 1 | import { tDays } from '../../index' 2 | import { dayOfWeek } from '../util/dayOfWeek' 3 | import { hours24 } from '../util/HoursAday' 4 | import { removeSchedule, schedules } from '../store/modules/schedule' 5 | import { useDispatch, useSelector } from 'react-redux' 6 | import { Dispatch, SetStateAction, useEffect, useState } from 'react' 7 | 8 | export default function ScheduleCalendar({ 9 | days, 10 | setModalDate, 11 | setTimeIndex, 12 | setIsOpenModal, 13 | isDeleteOpen, 14 | setIsDeleteOpen, 15 | }: { 16 | days: tDays[] 17 | setModalDate: Dispatch> 18 | setTimeIndex: Dispatch> 19 | setIsOpenModal: Dispatch> 20 | isDeleteOpen: boolean 21 | setIsDeleteOpen: Dispatch> 22 | }) { 23 | const dispatch = useDispatch() 24 | const scheduleData = useSelector(schedules) 25 | const [deleteBox, setDeleteBox] = useState<{ top: number; left: number }>({ top: 100, left: 100 }) 26 | const [deleteSchedule, setDeleteSchedule] = useState<{ date: string; index: number }>({ 27 | date: '', 28 | index: 0, 29 | }) 30 | 31 | const modalHandle = (date: string, hour: number) => { 32 | setModalDate(date) 33 | setTimeIndex(hour) 34 | setIsOpenModal(true) 35 | setIsDeleteOpen(false) 36 | } 37 | 38 | const scheduleHandle = ( 39 | cursor: { top: number; left: number }, 40 | scheduleData: { date: string; index: number } 41 | ) => { 42 | setIsOpenModal(false) 43 | setIsDeleteOpen(true) 44 | setDeleteBox(cursor) 45 | setDeleteSchedule(scheduleData) 46 | } 47 | 48 | const deleteHandle = () => { 49 | setIsDeleteOpen(false) 50 | dispatch(removeSchedule({ date: deleteSchedule.date, index: deleteSchedule.index })) 51 | } 52 | 53 | useEffect(() => { 54 | if (isDeleteOpen) { 55 | document.getElementById('schedule')!.style.overflow = 'hidden' 56 | } else { 57 | document.getElementById('schedule')!.style.overflow = 'auto' 58 | } 59 | }, [isDeleteOpen]) 60 | 61 | return ( 62 | <> 63 |
64 |
65 |
66 |
67 | {days.map((day, index) => ( 68 |
69 |
{dayOfWeek[index]}
70 |
71 |
75 | {day.date} 76 |
77 |
78 |
79 | ))} 80 |
81 |
82 |
83 | {hours24.map(hour => ( 84 |
85 | {hour.text}시 86 |
87 | ))} 88 |
89 |
90 | {days.map(day => ( 91 |
95 | {hours24.map((hour, index) => ( 96 |
modalHandle(day.day, index * 4)} 100 | /> 101 | ))} 102 | {scheduleData[day.day] && ( 103 | <> 104 | {scheduleData[day.day].map((s, idx) => { 105 | const t = s.start.hour * 60 + s.start.minute 106 | const top = `${t}px` 107 | let h = (s.end.hour - s.start.hour) * 60 - s.start.minute + s.end.minute 108 | if (h < 20) h = 20 109 | const height = `${h}px` 110 | return ( 111 |
{ 117 | scheduleHandle( 118 | { top: e.clientY, left: e.clientX }, 119 | { date: day.day, index: idx } 120 | ) 121 | }} 122 | > 123 | {s.title} 124 |
125 | ) 126 | })} 127 | 128 | )} 129 |
130 | ))} 131 |
132 |
133 |
134 |
135 | {isDeleteOpen && ( 136 |
deleteHandle()} 140 | > 141 | 삭제 142 |
143 | )} 144 | 145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /src/components/AddScheduleModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, useEffect, useState } from 'react' 2 | import createSelectTimes from '../util/createSelectTimes' 3 | import { useDispatch } from 'react-redux' 4 | import { tRangeColor, tScheduleDetail } from '../../index' 5 | import { addSchedule } from '../store/modules/schedule' 6 | 7 | export default function AddScheduleModal({ 8 | defaultDate, 9 | timeIndex, 10 | isOpen, 11 | setIsOpen, 12 | }: { 13 | defaultDate: string 14 | timeIndex: number 15 | isOpen: boolean 16 | setIsOpen: Dispatch> 17 | }) { 18 | const dispatch = useDispatch() 19 | const [isSelectStartTime, setIsSelectStartTime] = useState(false) 20 | const [isSelectEndTime, setIsSelectEndTime] = useState(false) 21 | 22 | const [title, setTitle] = useState('') 23 | const [date, setDate] = useState('2021-12-31') 24 | const [color, setColor] = useState('red') 25 | const [startHour, setStartHour] = useState(12) 26 | const [startMinute, setStartMinute] = useState(12) 27 | const [endHour, setEndHour] = useState(0) 28 | const [endMinute, setEndMinute] = useState(0) 29 | 30 | const [startSelectTimeIndex, setStartSelectTimeIndex] = useState(0) 31 | const [endSelectTimeIndex, setEndSelectTimeIndex] = useState(-1) 32 | 33 | const [displayStartTime, setDisplayStartTime] = useState('') 34 | const [displayEndTime, setDisplayEndTime] = useState('') 35 | 36 | const selectTimes: Array<{ hour: number; minute: string; text: string }> = createSelectTimes() 37 | const colors: tRangeColor[] = ['red', 'orange', 'green', 'blue', 'brown', 'pink'] 38 | 39 | const startTimeChange = (hour: number, minute: string, text: string, index: number) => { 40 | if (endSelectTimeIndex < index) { 41 | endTimeChange(hour, minute, text, index) 42 | } 43 | setStartSelectTimeIndex(index) 44 | setIsSelectStartTime(false) 45 | setDisplayStartTime(text) 46 | setStartHour(hour) 47 | setStartMinute(parseInt(minute)) 48 | } 49 | 50 | const endTimeChange = (hour: number, minute: string, text: string, index: number) => { 51 | setEndSelectTimeIndex(index) 52 | setIsSelectEndTime(false) 53 | setDisplayEndTime(text) 54 | setEndHour(hour) 55 | setEndMinute(parseInt(minute)) 56 | } 57 | 58 | const submitHandle = (e: React.FormEvent) => { 59 | e.preventDefault() 60 | setIsOpen(false) 61 | setTitle('') 62 | const schedule: { date: string; data: tScheduleDetail } = { 63 | date: date, 64 | data: { 65 | start: { hour: startHour, minute: startMinute }, 66 | end: { hour: endHour, minute: endMinute }, 67 | color: color, 68 | title: title, 69 | }, 70 | } 71 | dispatch(addSchedule(schedule)) 72 | } 73 | 74 | useEffect(() => { 75 | setDate(defaultDate) 76 | const defaultTime = selectTimes[timeIndex] 77 | startTimeChange(defaultTime.hour, defaultTime.minute, defaultTime.text, timeIndex) 78 | }, [defaultDate, timeIndex]) 79 | 80 | return ( 81 |
86 |
87 | setIsOpen(false)} 95 | > 96 | 97 | 98 | 99 |
100 |
101 | setTitle(e.target.value)} 107 | required 108 | /> 109 |
110 | { 115 | setDate(e.target.value) 116 | }} 117 | /> 118 | {isSelectStartTime && ( 119 |
120 | {selectTimes.map((time, index) => ( 121 |
startTimeChange(time.hour, time.minute, time.text, index)} 125 | > 126 | {time.text} 127 |
128 | ))} 129 |
130 | )} 131 | { 134 | setIsSelectStartTime(true) 135 | setIsSelectEndTime(false) 136 | }} 137 | > 138 | {displayStartTime} 139 | 140 | - 141 | {isSelectEndTime && ( 142 |
143 | {selectTimes.slice(startSelectTimeIndex).map((time, index) => ( 144 |
endTimeChange(time.hour, time.minute, time.text, index)} 148 | > 149 | {time.text} 150 |
151 | ))} 152 |
153 | )} 154 | { 157 | setIsSelectEndTime(true) 158 | setIsSelectStartTime(false) 159 | }} 160 | > 161 | {displayEndTime} 162 | 163 |
164 |
165 | {colors.map(clr => ( 166 |
setColor(clr)} 172 | /> 173 | ))} 174 |
175 |
176 | 182 |
183 | 184 |
185 | ) 186 | } 187 | --------------------------------------------------------------------------------