├── src ├── vite-env.d.ts ├── index.tsx ├── styled.d.ts ├── theme.ts ├── atoms.tsx ├── Components │ ├── DraggableCard.tsx │ └── Board.tsx └── App.tsx ├── vite.config.ts ├── .gitignore ├── README.md ├── tsconfig.json ├── index.html ├── package.json ├── LICENSE └── public └── vite.svg /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import App from "@/App"; 2 | import ReactDOM from "react-dom/client"; 3 | import { RecoilRoot } from "recoil"; 4 | 5 | const root = ReactDOM.createRoot( 6 | document.getElementById("root") as HTMLElement 7 | ); 8 | 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { resolve } from "path"; 3 | import { defineConfig } from "vite"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | base: "/nomad-trello-clone/", 14 | }); 15 | -------------------------------------------------------------------------------- /.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 | /dist 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nomad-trello-clone 2 | [`react-beautiful-dnd`](https://github.com/atlassian/react-beautiful-dnd)를 활용한 Trello 느낌의 To-do 보드입니다. 3 | 4 | ![nomad-trello-clone](https://user-images.githubusercontent.com/56245920/215138134-ede92521-476a-4149-a85a-0eac4708f911.png) 5 | 6 | ## 데모 7 | ✨ [여기](https://te6.in/nomad-trello-clone)에서 직접 사용해볼 수 있습니다. 8 | 9 | ## 시작하기 10 | ```shell 11 | git clone https://github.com/te6-in/nomad-trello-clone.git 12 | cd nomad-trello-clone 13 | npm install 14 | ``` 15 | -------------------------------------------------------------------------------- /src/styled.d.ts: -------------------------------------------------------------------------------- 1 | import "styled-components"; 2 | 3 | declare module "styled-components" { 4 | export interface DefaultTheme { 5 | bgColor: string; 6 | textColor: string; 7 | secondaryTextColor: string; 8 | accentColor: string; 9 | accentFadedColor: string; 10 | cardColor: string; 11 | boardColor: string; 12 | activeCardColor: string; 13 | buttonColor: string; 14 | hoverButtonColor: string; 15 | hoverButtonOverlayColor: string; 16 | glassColor: string; 17 | scrollBarColor: string; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["src/*"] 21 | } 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Nomad Trello Clone 9 | 15 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nomad-trello-clone", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "deploy": "gh-pages -d dist", 11 | "predeploy": "npm run build" 12 | }, 13 | "dependencies": { 14 | "react": "^18.2.0", 15 | "react-beautiful-dnd": "^13.1.1", 16 | "react-dom": "^18.2.0", 17 | "react-hook-form": "^7.38.0", 18 | "recoil": "^0.7.6", 19 | "styled-components": "^5.3.6" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^18.11.18", 23 | "@types/react": "^18.0.27", 24 | "@types/react-beautiful-dnd": "^13.1.2", 25 | "@types/react-dom": "^18.0.10", 26 | "@types/styled-components": "^5.1.26", 27 | "@vitejs/plugin-react": "^3.0.1", 28 | "gh-pages": "^5.0.0", 29 | "typescript": "^4.9.4", 30 | "vite": "^4.0.4" 31 | }, 32 | "homepage": "https://te6-in.github.io/nomad-trello-clone" 33 | } 34 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme } from "styled-components"; 2 | 3 | export const lightTheme: DefaultTheme = { 4 | bgColor: "#e7e7e9", 5 | textColor: "#222222", 6 | secondaryTextColor: "#666666", 7 | accentColor: "royalblue", 8 | accentFadedColor: "#e2e8f9", 9 | cardColor: "white", 10 | boardColor: "#efefef", 11 | activeCardColor: "#f7f7f7", 12 | buttonColor: "#ededed", 13 | hoverButtonColor: "#cfcfcf", 14 | hoverButtonOverlayColor: "rgba(0, 0, 0, 0.1)", 15 | glassColor: "rgba(188, 188, 188, 0.25)", 16 | scrollBarColor: "#bababa", 17 | }; 18 | 19 | export const darkTheme: DefaultTheme = { 20 | bgColor: "#222222", 21 | textColor: "#efefef", 22 | secondaryTextColor: "#adadad", 23 | accentColor: "royalblue", 24 | accentFadedColor: "#e2e8f9", 25 | cardColor: "#3b3b3b", 26 | boardColor: "#292a2c", 27 | activeCardColor: "#4d4d4d", 28 | buttonColor: "#555555", 29 | hoverButtonColor: "#333333", 30 | hoverButtonOverlayColor: "rgba(255, 255, 255, 0.1)", 31 | glassColor: "rgba(20, 20, 20, 0.25)", 32 | scrollBarColor: "#494949", 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chanhwi Joo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/atoms.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | 3 | export const isLightState = atom({ 4 | key: "isLigh", 5 | default: window.matchMedia("(prefers-color-scheme: light)").matches 6 | ? true 7 | : false, 8 | }); 9 | 10 | export interface IToDo { 11 | id: number; 12 | text: string; 13 | } 14 | 15 | export interface IBoard { 16 | id: number; 17 | title: string; 18 | toDos: IToDo[]; 19 | } 20 | 21 | const instanceOfToDo = (object: unknown): object is IToDo => { 22 | return ( 23 | object !== null && 24 | object !== undefined && 25 | object.constructor === Object && 26 | typeof (object as { id: unknown; text: unknown }).id === "number" && 27 | typeof (object as { id: unknown; text: unknown }).text === "string" 28 | ); 29 | }; 30 | 31 | const instanceOfBoard = (object: unknown): object is IBoard => { 32 | return ( 33 | object !== null && 34 | object !== undefined && 35 | object.constructor === Object && 36 | typeof (object as { id: unknown; title: unknown; toDos: unknown }).id === 37 | "number" && 38 | typeof (object as { id: unknown; title: unknown; toDos: unknown }).title === 39 | "string" && 40 | Array.isArray( 41 | (object as { id: unknown; title: unknown; toDos: unknown }).toDos 42 | ) && 43 | (object as { id: unknown; title: unknown; toDos: unknown[] }).toDos.every( 44 | (toDo) => instanceOfToDo(toDo) 45 | ) 46 | ); 47 | }; 48 | 49 | const instanceOfBoards = (object: unknown): object is IBoard[] => { 50 | return ( 51 | Array.isArray(object) && object.every((board) => instanceOfBoard(board)) 52 | ); 53 | }; 54 | 55 | const localStorageEffect = 56 | (key: string) => 57 | ({ setSelf, onSet }: any) => { 58 | const savedValue = localStorage.getItem(key); 59 | 60 | if (savedValue !== null && savedValue !== undefined) { 61 | const json = (raw: string) => { 62 | try { 63 | return JSON.parse(raw); 64 | } catch (error) { 65 | return false; 66 | } 67 | }; 68 | 69 | if (json(savedValue) && instanceOfBoards(json(savedValue))) { 70 | setSelf(json(savedValue)); 71 | } 72 | } 73 | 74 | onSet((newValue: IBoard[]) => { 75 | localStorage.setItem(key, JSON.stringify(newValue)); 76 | }); 77 | }; 78 | 79 | export const toDosState = atom({ 80 | key: "toDos", 81 | default: [ 82 | { 83 | title: "해야 함", 84 | id: 0, 85 | toDos: [ 86 | { text: "빨래 널기", id: 0 }, 87 | { text: "코로나 검사하기", id: 1 }, 88 | { text: "책 읽기", id: 2 }, 89 | { text: "마스크 사기", id: 3 }, 90 | { text: "커피 마시기", id: 4 }, 91 | { text: "설거지 하기", id: 5 }, 92 | { text: "공부하기", id: 6 }, 93 | { text: "운동하기", id: 7 }, 94 | { text: "이건 이름이 되게 긴데 마우스를 여기에도 올려보세요", id: 8 }, 95 | { text: "테스트 1", id: 9 }, 96 | { text: "테스트 2", id: 10 }, 97 | { text: "테스트 3", id: 11 }, 98 | { text: "테스트 4", id: 12 }, 99 | { text: "스크롤이랑 드래그도", id: 13 }, 100 | { text: "해보세요", id: 14 }, 101 | { text: "할 일 1", id: 15 }, 102 | { text: "할 일 2", id: 16 }, 103 | { text: "할 일 3", id: 17 }, 104 | { text: "할 일 4", id: 18 }, 105 | { text: "할 일 5", id: 19 }, 106 | { text: "할 일 6", id: 20 }, 107 | { text: "할 일 7", id: 21 }, 108 | { text: "할 일 8", id: 22 }, 109 | { text: "할 일 9", id: 23 }, 110 | { text: "할 일 10", id: 24 }, 111 | { text: "할 일 11", id: 25 }, 112 | { text: "할 일 12", id: 26 }, 113 | ], 114 | }, 115 | { title: "하는 중", id: 1, toDos: [] }, 116 | { 117 | title: "끝", 118 | id: 2, 119 | toDos: [ 120 | { text: "은행 다녀오기", id: 27 }, 121 | { text: "보드나 할 일을 추가해보세요!", id: 28 }, 122 | ], 123 | }, 124 | ], 125 | effects: [localStorageEffect("trello-clone-to-dos")], 126 | }); 127 | -------------------------------------------------------------------------------- /src/Components/DraggableCard.tsx: -------------------------------------------------------------------------------- 1 | import { IToDo, toDosState } from "@/atoms"; 2 | import { MaterialIcon } from "@/Components/Board"; 3 | import React from "react"; 4 | import { Draggable } from "react-beautiful-dnd"; 5 | import { useSetRecoilState } from "recoil"; 6 | import styled from "styled-components"; 7 | 8 | const Button = styled.button` 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | border-radius: 0.2rem; 13 | width: 2rem; 14 | height: 2rem; 15 | transition: opacity 0.3s, background-color 0.3s, color 0.3s; 16 | cursor: pointer; 17 | color: ${(props) => props.theme.secondaryTextColor}; 18 | background-color: transparent; 19 | border: none; 20 | font-size: 1.2rem; 21 | 22 | &:hover, 23 | &:active, 24 | &:focus-within { 25 | background-color: ${(props) => props.theme.hoverButtonOverlayColor}; 26 | } 27 | `; 28 | 29 | const Buttons = styled.div` 30 | display: flex; 31 | position: absolute; 32 | top: calc(50% - 1rem); 33 | right: 0.375rem; 34 | justify-content: space-between; 35 | gap: 0.125rem; 36 | `; 37 | 38 | const Card = styled.li` 39 | background-color: ${(props) => props.theme.cardColor}; 40 | padding: 0.8rem; 41 | border-radius: 0.4rem; 42 | box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); 43 | margin-bottom: 0.6rem; 44 | height: 2.75rem; 45 | display: block; 46 | transition: background-color 0.3s, color 0.3s, box-shadow 0.3s, opacity 0.3s; 47 | user-select: none; 48 | position: relative; 49 | font-size: 1rem; 50 | 51 | &.dragging { 52 | box-shadow: 0 0.4rem 0.8rem rgba(0, 0, 0, 0.25); 53 | } 54 | 55 | &.dragging-over-trash { 56 | background-color: tomato !important; 57 | color: white; 58 | } 59 | 60 | &:focus-within { 61 | background-color: ${(props) => props.theme.accentColor}; 62 | outline: 0.15rem solid ${(props) => props.theme.textColor}; 63 | color: white; 64 | } 65 | 66 | & > :first-child { 67 | width: 100%; 68 | text-overflow: ellipsis; 69 | overflow: hidden; 70 | white-space: nowrap; 71 | transition: width 0.3s; 72 | margin-top: 0.1rem; 73 | } 74 | 75 | &:not(:hover):not(:focus-within) ${Button} { 76 | opacity: 0; 77 | } 78 | 79 | &:hover > :first-child, 80 | &:focus-within > :first-child { 81 | width: 8.75rem; 82 | } 83 | 84 | &:focus-within ${Button} { 85 | color: white; 86 | } 87 | 88 | &:focus-within ${Button}:focus { 89 | outline: 0.15rem solid white; 90 | } 91 | `; 92 | 93 | interface IDraggableCardProps { 94 | toDo: IToDo; 95 | index: number; 96 | boardId: number; 97 | } 98 | 99 | function DraggableCard({ toDo, index, boardId }: IDraggableCardProps) { 100 | const setToDos = useSetRecoilState(toDosState); 101 | 102 | const onEdit = () => { 103 | const newToDoText = window 104 | .prompt(`${toDo.text} 할 일의 새 이름을 입력해주세요.`, toDo.text) 105 | ?.trim(); 106 | 107 | if (newToDoText !== null && newToDoText !== undefined) { 108 | if (newToDoText === "") { 109 | alert("이름을 입력해주세요."); 110 | return; 111 | } 112 | 113 | setToDos((prev) => { 114 | const toDosCopy = [...prev]; 115 | const boardIndex = toDosCopy.findIndex((board) => board.id === boardId); 116 | const boardCopy = { ...toDosCopy[boardIndex] }; 117 | const listCopy = [...boardCopy.toDos]; 118 | const toDoIndex = boardCopy.toDos.findIndex((td) => td.id === toDo.id); 119 | 120 | listCopy.splice(toDoIndex, 1, { 121 | text: newToDoText, 122 | id: toDo.id, 123 | }); 124 | 125 | boardCopy.toDos = listCopy; 126 | toDosCopy.splice(boardIndex, 1, boardCopy); 127 | 128 | return toDosCopy; 129 | }); 130 | } 131 | }; 132 | 133 | const onDelete = () => { 134 | if (window.confirm(`${toDo.text} 할 일을 삭제하시겠습니까?`)) { 135 | setToDos((prev) => { 136 | const toDosCopy = [...prev]; 137 | const boardIndex = toDosCopy.findIndex((board) => board.id === boardId); 138 | const boardCopy = { ...toDosCopy[boardIndex] }; 139 | const listCopy = [...boardCopy.toDos]; 140 | const toDoIndex = boardCopy.toDos.findIndex((td) => td.id === toDo.id); 141 | 142 | listCopy.splice(toDoIndex, 1); 143 | boardCopy.toDos = listCopy; 144 | toDosCopy.splice(boardIndex, 1, boardCopy); 145 | 146 | return toDosCopy; 147 | }); 148 | } 149 | }; 150 | 151 | return ( 152 | 153 | {(provided, snapshot) => ( 154 | 162 |
{toDo.text}
163 | 164 | 167 | 170 | 171 |
172 | )} 173 |
174 | ); 175 | } 176 | 177 | export default React.memo(DraggableCard); 178 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { isLightState, toDosState } from "@/atoms"; 2 | import Board, { MaterialIcon } from "@/Components/Board"; 3 | import { darkTheme, lightTheme } from "@/theme"; 4 | import { useEffect } from "react"; 5 | import { 6 | DragDropContext, 7 | Draggable, 8 | DraggingStyle, 9 | Droppable, 10 | DropResult, 11 | NotDraggingStyle, 12 | } from "react-beautiful-dnd"; 13 | import { useRecoilState } from "recoil"; 14 | import styled, { createGlobalStyle, ThemeProvider } from "styled-components"; 15 | 16 | const Trash = styled.div` 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | position: fixed; 21 | top: -3.75rem; 22 | left: calc(50vw - 3.75rem); 23 | width: 7.5rem; 24 | height: 3.75rem; 25 | border-radius: 0 0 100rem 100rem; 26 | background-color: tomato; 27 | box-shadow: -0.1rem 0 0.4rem rgb(210 77 77 / 15%); 28 | font-size: 2.5rem; 29 | z-index: 5; 30 | transition: transform 0.3s; 31 | 32 | & > div { 33 | margin-bottom: 0.5rem; 34 | color: rgba(0, 0, 0, 0.5); 35 | } 36 | `; 37 | 38 | const GlobalStyle = createGlobalStyle` 39 | html, body, div, span, applet, object, iframe, 40 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 41 | a, abbr, acronym, address, big, cite, code, 42 | del, dfn, em, img, ins, kbd, q, s, samp, 43 | small, strike, strong, sub, sup, tt, var, 44 | b, u, i, center, 45 | dl, dt, dd, ol, ul, li, 46 | fieldset, form, label, legend, 47 | table, caption, tbody, tfoot, thead, tr, th, td, 48 | article, aside, canvas, details, embed, 49 | figure, figcaption, footer, header, hgroup, 50 | menu, nav, output, ruby, section, summary, 51 | time, mark, audio, video { 52 | margin: 0; 53 | padding: 0; 54 | border: 0; 55 | font-size: 100%; 56 | font: inherit; 57 | vertical-align: baseline; 58 | } 59 | /* HTML5 display-role reset for older browsers */ 60 | article, aside, details, figcaption, figure, 61 | footer, header, hgroup, menu, nav, section { 62 | display: block; 63 | } 64 | body { 65 | display: flex; 66 | align-items: flex-start; 67 | justify-content: flex-start; 68 | line-height: 1; 69 | font-family: "Pretendard", sans-serif; 70 | background-color: ${(props) => props.theme.bgColor}; 71 | color: ${(props) => props.theme.textColor}; 72 | transition: background-color 0.3s, color 0.3s; 73 | overflow-y: hidden; 74 | } 75 | ol, ul { 76 | list-style: none; 77 | } 78 | blockquote, q { 79 | quotes: none; 80 | } 81 | blockquote:before, blockquote:after, 82 | q:before, q:after { 83 | content: ''; 84 | content: none; 85 | } 86 | table { 87 | border-collapse: collapse; 88 | border-spacing: 0; 89 | } 90 | a { 91 | text-decoration: none; 92 | color: inherit; 93 | } 94 | * { 95 | box-sizing: border-box; 96 | } 97 | input, button { 98 | font-family: "Pretendard", sans-serif; 99 | color: inherit; 100 | } 101 | &:has(.dragging) ${Trash} { 102 | transform: translateY(3.75rem); 103 | } 104 | &:has(.dragging-over-trash) ${Trash} { 105 | transform: translateY(3.75rem) scale(1.2); 106 | } 107 | `; 108 | 109 | const Title = styled.h1` 110 | font-size: 2rem; 111 | font-weight: 600; 112 | transition: color 0.3s; 113 | `; 114 | 115 | const Buttons = styled.div` 116 | display: flex; 117 | align-items: center; 118 | gap: 2rem; 119 | transition: color 0.3s; 120 | color: ${(props) => props.theme.secondaryTextColor}; 121 | `; 122 | 123 | const Button = styled.button` 124 | display: flex; 125 | align-items: center; 126 | justify-content: center; 127 | font-size: 1.8rem; 128 | width: 2rem; 129 | height: 2rem; 130 | background: none; 131 | border: none; 132 | transition: color 0.3s; 133 | padding: 0; 134 | border-radius: 0.2rem; 135 | 136 | &:hover, 137 | &:focus { 138 | cursor: pointer; 139 | color: ${(props) => props.theme.accentColor}; 140 | } 141 | 142 | &:focus { 143 | outline: 0.15rem solid ${(props) => props.theme.accentColor}; 144 | } 145 | `; 146 | 147 | const Boards = styled.div` 148 | display: flex; 149 | align-items: flex-start; 150 | justify-content: flex-start; 151 | min-width: calc(100vw - 4rem); 152 | margin-right: 2rem; 153 | height: calc(100vh - 10rem); 154 | margin-top: 7rem; 155 | margin-left: 2rem; 156 | `; 157 | 158 | const Navigation = styled.nav` 159 | display: flex; 160 | position: fixed; 161 | padding: 2.5rem 3rem; 162 | align-items: center; 163 | justify-content: space-between; 164 | width: 100vw; 165 | color: ${(props) => props.theme.textColor}; 166 | `; 167 | 168 | function getStyle(style: DraggingStyle | NotDraggingStyle) { 169 | if (style?.transform) { 170 | const axisLockX = `${style.transform.split(",").shift()}, 0px)`; 171 | return { 172 | ...style, 173 | transform: axisLockX, 174 | }; 175 | } 176 | return style; 177 | } 178 | 179 | function App() { 180 | const [isLight, setIsLight] = useRecoilState(isLightState); 181 | const toggleTheme = () => setIsLight((current) => !current); 182 | 183 | const [toDos, setToDos] = useRecoilState(toDosState); 184 | 185 | useEffect(() => { 186 | window 187 | .matchMedia("(prefers-color-scheme: light") 188 | .addEventListener("change", (e) => { 189 | setIsLight(e.matches); 190 | }); 191 | }); 192 | 193 | const onAdd = () => { 194 | const name = window.prompt("새 보드의 이름을 입력해주세요.")?.trim(); 195 | 196 | if (name !== null && name !== undefined) { 197 | if (name === "") { 198 | alert("이름을 입력해주세요."); 199 | return; 200 | } 201 | setToDos((prev) => { 202 | return [...prev, { title: name, id: Date.now(), toDos: [] }]; 203 | }); 204 | } 205 | }; 206 | 207 | const onDragEnd = ({ draggableId, source, destination }: DropResult) => { 208 | if (source.droppableId === "boards") { 209 | if (!destination) return; 210 | 211 | if (source.index === destination.index) return; 212 | 213 | // 보드 순서 변경 214 | if (source.index !== destination.index) { 215 | setToDos((prev) => { 216 | const toDosCopy = [...prev]; 217 | const prevBoard = toDosCopy[source.index]; 218 | 219 | toDosCopy.splice(source.index, 1); 220 | toDosCopy.splice(destination.index, 0, prevBoard); 221 | 222 | return toDosCopy; 223 | }); 224 | } 225 | } else if (source.droppableId !== "boards") { 226 | if (!destination) return; 227 | 228 | // 태스크 삭제 229 | if (destination.droppableId === "trash") { 230 | setToDos((prev) => { 231 | const toDosCopy = [...prev]; 232 | const boardIndex = toDosCopy.findIndex( 233 | (board) => board.id + "" === source.droppableId.split("-")[1] 234 | ); 235 | const boardCopy = { ...toDosCopy[boardIndex] }; 236 | const listCopy = [...boardCopy.toDos]; 237 | 238 | listCopy.splice(source.index, 1); 239 | boardCopy.toDos = listCopy; 240 | toDosCopy.splice(boardIndex, 1, boardCopy); 241 | 242 | return toDosCopy; 243 | }); 244 | return; 245 | } 246 | 247 | // 태스크 순서 변경(보드 내) 248 | if (source.droppableId === destination.droppableId) { 249 | setToDos((prev) => { 250 | const toDosCopy = [...prev]; 251 | const boardIndex = toDosCopy.findIndex( 252 | (board) => board.id + "" === source.droppableId.split("-")[1] 253 | ); 254 | const boardCopy = { ...toDosCopy[boardIndex] }; 255 | const listCopy = [...boardCopy.toDos]; 256 | const prevToDo = boardCopy.toDos[source.index]; 257 | 258 | listCopy.splice(source.index, 1); 259 | listCopy.splice(destination.index, 0, prevToDo); 260 | 261 | boardCopy.toDos = listCopy; 262 | toDosCopy.splice(boardIndex, 1, boardCopy); 263 | 264 | return toDosCopy; 265 | }); 266 | } 267 | 268 | // 태스크 순서 변경(보드 간) 269 | if (source.droppableId !== destination.droppableId) { 270 | setToDos((prev) => { 271 | const toDosCopy = [...prev]; 272 | 273 | const sourceBoardIndex = toDosCopy.findIndex( 274 | (board) => board.id + "" === source.droppableId.split("-")[1] 275 | ); 276 | const destinationBoardIndex = toDosCopy.findIndex( 277 | (board) => board.id + "" === destination.droppableId.split("-")[1] 278 | ); 279 | 280 | const sourceBoardCopy = { ...toDosCopy[sourceBoardIndex] }; 281 | const destinationBoardCopy = { ...toDosCopy[destinationBoardIndex] }; 282 | 283 | const sourceListCopy = [...sourceBoardCopy.toDos]; 284 | const destinationListCopy = [...destinationBoardCopy.toDos]; 285 | 286 | const prevToDo = sourceBoardCopy.toDos[source.index]; 287 | 288 | sourceListCopy.splice(source.index, 1); 289 | destinationListCopy.splice(destination.index, 0, prevToDo); 290 | 291 | sourceBoardCopy.toDos = sourceListCopy; 292 | destinationBoardCopy.toDos = destinationListCopy; 293 | 294 | toDosCopy.splice(sourceBoardIndex, 1, sourceBoardCopy); 295 | toDosCopy.splice(destinationBoardIndex, 1, destinationBoardCopy); 296 | 297 | return toDosCopy; 298 | }); 299 | } 300 | } 301 | }; 302 | 303 | return ( 304 | 305 | 306 | 307 | 할 일 308 | 309 | 312 | 315 | 316 | 317 | 318 | 319 | {(provided, snapshot) => ( 320 | 321 | {toDos.map((board, index) => ( 322 | 327 | {(provided, snapshot) => ( 328 | 334 | )} 335 | 336 | ))} 337 | {provided.placeholder} 338 | 339 | )} 340 | 341 | 342 | {(provided, snapshot) => ( 343 |
344 | 345 | 346 | 347 | {provided.placeholder} 348 |
349 | )} 350 |
351 |
352 |
353 | ); 354 | } 355 | 356 | export default App; 357 | -------------------------------------------------------------------------------- /src/Components/Board.tsx: -------------------------------------------------------------------------------- 1 | import { IBoard, toDosState } from "@/atoms"; 2 | import DraggableCard from "@/Components/DraggableCard"; 3 | import React, { useState } from "react"; 4 | import { 5 | DraggableProvided, 6 | DraggingStyle, 7 | Droppable, 8 | NotDraggingStyle, 9 | } from "react-beautiful-dnd"; 10 | import { useForm } from "react-hook-form"; 11 | import { useSetRecoilState } from "recoil"; 12 | import styled from "styled-components"; 13 | 14 | interface MaterialIconProps { 15 | name: string; 16 | Tag?: keyof JSX.IntrinsicElements; 17 | } 18 | 19 | export function MaterialIcon({ name, Tag = "div" }: MaterialIconProps) { 20 | return ( 21 | 25 | {name} 26 | 27 | ); 28 | } 29 | 30 | const Overlay = styled.div` 31 | width: 100%; 32 | height: 100%; 33 | display: flex; 34 | justify-content: space-between; 35 | flex-direction: column; 36 | position: absolute; 37 | pointer-events: none; 38 | z-index: 1; 39 | 40 | & > * { 41 | pointer-events: all; 42 | } 43 | `; 44 | 45 | const Title = styled.div` 46 | display: block; 47 | font-size: 1.6rem; 48 | font-weight: 600; 49 | width: 16rem; 50 | height: 4.5rem; 51 | padding: 1.25rem; 52 | border-radius: 0.8rem 0.8rem 0 0; 53 | transition: background-color 0.3s, color 0.3s, box-shadow 0.3s, opacity 0.3s; 54 | user-select: none; 55 | 56 | & > h2 { 57 | width: 13.5rem; 58 | margin-top: 0.2rem; 59 | text-overflow: ellipsis; 60 | overflow: hidden; 61 | white-space: nowrap; 62 | transition: width 0.3s; 63 | } 64 | 65 | &.background { 66 | background-color: ${(props) => props.theme.glassColor}; 67 | backdrop-filter: blur(0.4rem); 68 | box-shadow: 0 0.1rem 0.2rem rgba(0, 0, 0, 0.15); 69 | } 70 | `; 71 | 72 | const Buttons = styled.div` 73 | display: flex; 74 | position: absolute; 75 | top: 1.25rem; 76 | right: 1.25rem; 77 | align-items: center; 78 | gap: 0.2rem; 79 | color: ${(props) => props.theme.secondaryTextColor}; 80 | transition: opacity 0.3s; 81 | `; 82 | 83 | const Button = styled.button` 84 | display: flex; 85 | align-items: center; 86 | justify-content: center; 87 | font-size: 1.5rem; 88 | width: 2rem; 89 | height: 2rem; 90 | background: none; 91 | border: none; 92 | padding: 0; 93 | border-radius: 0.2rem; 94 | color: ${(props) => props.theme.secondaryTextColor}; 95 | transition: background-color 0.3s, color 0.3s, opacity 0.3s; 96 | 97 | &:hover, 98 | &:active, 99 | &:focus { 100 | cursor: pointer; 101 | background-color: ${(props) => props.theme.hoverButtonOverlayColor}; 102 | } 103 | 104 | &:focus { 105 | opacity: 1; 106 | outline: 0.15rem solid ${(props) => props.theme.accentColor}; 107 | } 108 | 109 | &:last-child { 110 | cursor: grab; 111 | } 112 | `; 113 | 114 | const Form = styled.form` 115 | width: 100%; 116 | height: 3.5rem; 117 | display: flex; 118 | align-items: center; 119 | justify-content: space-between; 120 | bottom: 0; 121 | transition: background-color 0.3s, color 0.3s, opacity 0.3s; 122 | 123 | & :focus { 124 | outline: 0.15rem solid ${(props) => props.theme.accentColor}; 125 | } 126 | 127 | & > input { 128 | width: 100%; 129 | height: 100%; 130 | padding: 0 1.2rem; 131 | border: none; 132 | border-radius: 0 0 0.8rem 0.8rem; 133 | background-color: ${(props) => props.theme.cardColor}; 134 | box-shadow: 0 -0.1rem 0.2rem rgba(0, 0, 0, 0.15); 135 | font-size: 1rem; 136 | font-weight: 500; 137 | color: ${(props) => props.theme.textColor}; 138 | transition: background-color 0.3s, box-shadow 0.3s; 139 | } 140 | 141 | & > button { 142 | position: absolute; 143 | right: 0; 144 | width: 3.5rem; 145 | height: 3.5rem; 146 | background-color: transparent; 147 | border: none; 148 | border-radius: 0 0 0.8rem 0; 149 | font-size: 1.4rem; 150 | display: flex; 151 | align-items: center; 152 | justify-content: center; 153 | color: ${(props) => props.theme.accentColor}; 154 | } 155 | 156 | & > input:placeholder-shown + button { 157 | display: none; 158 | } 159 | `; 160 | 161 | const ToDos = styled.ul` 162 | display: flex; 163 | flex-direction: column; 164 | padding: 4.5rem 0.4rem 4rem 1rem; 165 | width: 100%; 166 | max-height: calc(100vh - 11rem); 167 | overflow-x: hidden; 168 | overflow-y: scroll; 169 | 170 | &::-webkit-scrollbar { 171 | width: 0.6rem; 172 | } 173 | 174 | &::-webkit-scrollbar-thumb { 175 | background-color: ${(props) => props.theme.scrollBarColor}; 176 | border-radius: 0.3rem; 177 | background-clip: padding-box; 178 | border: 0.2rem solid transparent; 179 | transition: background-color 0.3s; 180 | } 181 | `; 182 | 183 | const Empty = styled.li` 184 | display: flex; 185 | align-items: center; 186 | justify-content: center; 187 | color: ${(props) => props.theme.secondaryTextColor}; 188 | font-size: 0.9rem; 189 | margin-top: 0.9rem; 190 | margin-bottom: 1.6rem; 191 | cursor: default; 192 | transition: color 0.3s; 193 | user-select: none; 194 | `; 195 | 196 | const Container = styled.div<{ isDraggingOver: boolean }>` 197 | display: flex; 198 | width: 16rem; 199 | max-height: calc(100vh - 8rem); 200 | position: relative; 201 | background-color: ${(props) => 202 | props.isDraggingOver ? props.theme.accentColor : props.theme.boardColor}; 203 | border-radius: 0.8rem; 204 | box-shadow: 0 0.3rem 0.6rem rgba(0, 0, 0, 0.15); 205 | margin: 0.5rem; 206 | transition: background-color 0.3s, box-shadow 0.3s; 207 | 208 | &.hovering { 209 | box-shadow: 0 0.6rem 1.2rem rgba(0, 0, 0, 0.25); 210 | } 211 | 212 | &:has(li.dragging) ${Title}.background, &.dragging ${Title}.background { 213 | opacity: 0; 214 | } 215 | 216 | &.dragging ${Title} { 217 | color: white; 218 | 219 | & > h2 { 220 | width: 13.5rem !important; 221 | } 222 | } 223 | 224 | &:has(li.dragging) ${Form}, &.dragging ${Form} { 225 | opacity: 0; 226 | } 227 | 228 | &:has(li.dragging) ${Empty}, &.dragging ${Empty} { 229 | opacity: 0; 230 | } 231 | 232 | &.dragging ${ToDos}::-webkit-scrollbar-thumb { 233 | background-color: rgba(0, 0, 0, 0.3); 234 | } 235 | 236 | &:has(li.dragging) ${Form}.end, &.dragging ${Form}.end { 237 | opacity: 0.5; 238 | 239 | & > input { 240 | background-color: transparent; 241 | box-shadow: none; 242 | 243 | &::placeholder { 244 | color: white; 245 | } 246 | } 247 | } 248 | 249 | &:not(:hover):not(:focus-within) ${Buttons}, &.dragging ${Buttons} { 250 | opacity: 0; 251 | } 252 | 253 | &:hover ${Title} > h2, 254 | &:focus-within ${Title} > h2 { 255 | width: 7.1rem; 256 | } 257 | `; 258 | 259 | interface IBoardProps { 260 | board: IBoard; 261 | parentProvided: DraggableProvided; 262 | isHovering: boolean; 263 | style: DraggingStyle | NotDraggingStyle; 264 | } 265 | 266 | interface IForm { 267 | toDo: string; 268 | } 269 | 270 | function Board({ board, parentProvided, isHovering, style }: IBoardProps) { 271 | const setToDos = useSetRecoilState(toDosState); 272 | 273 | const [isScrolled, setIsScrolled] = useState(false); 274 | const [isEnd, setIsEnd] = useState(false); 275 | 276 | const onScroll = (event: React.UIEvent) => { 277 | if (event.currentTarget.scrollTop > 0) { 278 | setIsScrolled(true); 279 | } else { 280 | setIsScrolled(false); 281 | } 282 | 283 | if ( 284 | event.currentTarget.scrollHeight - event.currentTarget.scrollTop === 285 | event.currentTarget.clientHeight 286 | ) { 287 | setIsEnd(true); 288 | } else { 289 | setIsEnd(false); 290 | } 291 | }; 292 | 293 | const { register, setValue, handleSubmit } = useForm(); 294 | 295 | const onValid = ({ toDo }: IForm) => { 296 | if (toDo.trim() === "") { 297 | return; 298 | } 299 | 300 | const newToDo = { 301 | id: Date.now(), 302 | text: toDo, 303 | }; 304 | 305 | setToDos((prev) => { 306 | const toDosCopy = [...prev]; 307 | const boardIndex = prev.findIndex((b) => b.id === board.id); 308 | const boardCopy = { ...prev[boardIndex] }; 309 | 310 | boardCopy.toDos = [newToDo, ...boardCopy.toDos]; 311 | toDosCopy.splice(boardIndex, 1, boardCopy); 312 | 313 | return toDosCopy; 314 | }); 315 | 316 | setValue("toDo", ""); 317 | }; 318 | 319 | const onEdit = () => { 320 | const newName = window 321 | .prompt(`${board.title} 보드의 새 이름을 입력해주세요.`, board.title) 322 | ?.trim(); 323 | 324 | if (newName !== null && newName !== undefined) { 325 | if (newName === "") { 326 | alert("이름을 입력해주세요."); 327 | return; 328 | } 329 | 330 | if (newName === board.title) { 331 | return; 332 | } 333 | 334 | setToDos((prev) => { 335 | const toDosCopy = [...prev]; 336 | const boardIndex = toDosCopy.findIndex((b) => b.id === board.id); 337 | const boardCopy = { ...toDosCopy[boardIndex] }; 338 | 339 | boardCopy.title = newName; 340 | toDosCopy.splice(boardIndex, 1, boardCopy); 341 | 342 | return toDosCopy; 343 | }); 344 | } 345 | }; 346 | 347 | const onDelete = () => { 348 | if (window.confirm(`${board.title} 보드를 삭제하시겠습니까?`)) { 349 | setToDos((prev) => { 350 | const toDosCopy = [...prev]; 351 | const boardIndex = toDosCopy.findIndex((b) => b.id === board.id); 352 | 353 | toDosCopy.splice(boardIndex, 1); 354 | 355 | return toDosCopy; 356 | }); 357 | } 358 | }; 359 | 360 | return ( 361 | 362 | {(provided, snapshot) => ( 363 | 372 | 373 | 374 | <h2>{board.title}</h2> 375 | <Buttons> 376 | <Button onClick={onEdit}> 377 | <MaterialIcon name="edit" /> 378 | </Button> 379 | <Button onClick={onDelete}> 380 | <MaterialIcon name="delete" /> 381 | </Button> 382 | <Button as="div" {...parentProvided.dragHandleProps}> 383 | <MaterialIcon name="drag_handle" /> 384 | </Button> 385 | </Buttons> 386 | 387 |
391 | 396 | 399 |
400 |
401 | 406 | {board.toDos.map((toDo, index) => ( 407 | 413 | ))} 414 | {board.toDos.length === 0 ? ( 415 | 이 보드는 비어 있습니다. 416 | ) : null} 417 | {provided.placeholder} 418 | 419 |
420 | )} 421 |
422 | ); 423 | } 424 | 425 | export default React.memo(Board); 426 | --------------------------------------------------------------------------------