├── 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 | 
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 | {board.title}
375 |
376 |
379 |
382 |
385 |
386 |
387 |
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 |
--------------------------------------------------------------------------------