2024년 11월 5일
// App.tsx
import { useState } from 'react'
import { appContainer, board, buttons } from './App.css'
import BoardList from './components/BoardList/BoardList'
function App() {
const [activeBoardId, setActiveBoardId] = useState('board-0');
return (
<div className={appContainer}>
<BoardList activeBoardId={activeBoardId} setActiveBoardId={setActiveBoardId}/>
<div className={board}>
</div>
<div className={buttons}>
<button>
이 게시판 삭제하기
</button>
<button>
</button>
</div>
</div>
)
}
export default App
// components / BoardList / BoardList.tsx
import React, { FC, useState } from 'react'
import { useTypedSelector } from '../../hooks/redux';
import { FiPlusCircle } from 'react-icons/fi';
import SideForm from './SideForm/SideForm';
import { addButton, addSection, boardItem, boardItemActive, container, title } from './BoardList.css';
import clsx from 'clsx';
type TBoardListProps = {
activeBoardId: string;
setActiveBoardId: React.Dispatch<React.SetStateAction<string>>
}
const BoardList : FC<TBoardListProps> = ({
activeBoardId,
setActiveBoardId
}) => {
const { boardArray } = useTypedSelector(state => state.boards);
const [isFormOpen, setIsFormOpen] = useState(false);
return (
<div className={container}>
<div className={title}>게시판:</div>
{
boardArray.map((board, index) => (
<div key={board.boardId} onClick={() => setActiveBoardId(boardArray[index].boardId)} className={
clsx(
{
[boardItemActive] : boardArray.findIndex(b => b.boardId === activeBoardId) === index,
},
{
[boardItem] : boardArray.findIndex(b => b.boardId === activeBoardId) !== index
}
)}>
<div>{board.boardName}</div>
</div>
))
}
<div className={addSection}>
{
isFormOpen ? <SideForm setIsFormOpen={setIsFormOpen} /> : <FiPlusCircle className={addButton} onClick={() => setIsFormOpen(!isFormOpen)}/>
}
</div>
</div>
)
}
export default BoardList
// components / BoardList / BoardList.css.ts
import { style } from '@vanilla-extract/css'
import { vars } from '../../App.css'
export const container = style({
display: "flex",
flexDirection : "row",
alignItems : "center",
flexWrap : "wrap",
rowGap : 15,
minHeight : "max-content",
padding : vars.spacing.big2,
backgroundColor: vars.color.mainDarker,
})
export const title = style({
color : vars.color.brightText,
fontSize : vars.fontSizing.T2,
marginRight : vars.spacing.big1,
})
export const addButton = style({
color : vars.color.brightText,
fontSize : vars.fontSizing.T2,
cursor : "pointer",
marginLeft : vars.spacing.big1,
":hover" : {
opacity: 0.8
}
})
export const boardItem = style({
color : vars.color.brightText,
fontSize : vars.fontSizing.T3,
backgroundColor : vars.color.mainFaded,
padding : vars.spacing.medium,
borderRadius : 10,
cursor: "pointer",
marginRight : vars.spacing.big1,
":hover" : {
opacity : 0.8,
transform : "scale(1.03)"
}
})
export const boardItemActive = style({
color : vars.color.brightText,
fontSize : vars.fontSizing.T3,
backgroundColor : vars.color.selectedTab,
padding: vars.spacing.medium,
borderRadius : 10,
cursor: "pointer",
marginRight : vars.spacing.big1,
})
export const addSection = style({
display: "flex",
alignItems : "center",
marginLeft : "auto",
})
export const smallTitle = style({
color : vars.color.brightText,
fontSize : vars.fontSizing.T3,
})
// components / BoardList / SideForm / SideForm.tsx
import React, { ChangeEvent, FC, useState } from 'react'
import { FiCheck } from 'react-icons/fi';
import { icon, input, sideForm } from './SideForm.css';
import { useTypedDispatch } from '../../../hooks/redux';
import { v4 as uuidv4} from 'uuid';
import { addBoard } from '../../../store/slices/boardsSlice';
import { addLog } from '../../../store/slices/loggerSlice';
type TSideFormProps = {
setIsFormOpen : React.Dispatch<React.SetStateAction<boolean>>,
// inputRef : React.RefObject<HTMLInputElement>
}
const SideForm : FC<TSideFormProps> = ({
setIsFormOpen,
// inputRef
}) => {
const [inputText, setInputText] = useState('');
const dispatch = useTypedDispatch();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
}
const handleOnBlur = () => {
setIsFormOpen(false);
}
const handleClick = () => {
if(inputText) {
dispatch(
addBoard({
board : {
boardId: uuidv4(),
boardName: inputText,
lists: []
}
})
)
dispatch(
addLog({
logId : uuidv4(),
logMessage : `게시판 등록: ${inputText}`,
logAuthor : "User",
logTimeStamp: String(Date.now()),
})
)
}
}
return (
<div className={sideForm}>
<input
// ref={inputRef}
autoFocus
className={input}
type='text'
placeholder='새로운 게시판 등록하기'
value={inputText}
onChange={handleChange}
onBlur={handleOnBlur}
/>
<FiCheck className={icon} onMouseDown={handleClick}/>
</div>
// onClick 을 사용하면 blur와 순서가 겹치기 대문에 mousedown을 이벤트 프로퍼티로 넣는다.
// blur onmousedown => mouseup => click
)
}
export default SideForm
// store / slices / boardSlice.ts
... 생략 ,,,
type TAddBoardAction = {
board : IBoard;
}
... 생략 ...
const boardsSlice = createSlice({
name: 'boards',
initialState,
reducers : {
addBoard : (state, {payload} : PayloadAction<TAddBoardAction>) => {
state.boardArray.push(payload.board); // immer라는 라이브러리 사용해서 불변성 지키지 않아도됨
}
}
})
export const {addBoard} = boardsSlice.actions;
export const boardsReducer = boardsSlice.reducer;
// store / slies / loggerSlciees.ts
... 생략 ...
const loggerSlice = createSlice({
name: 'logger',
initialState,
reducers: {
addLog : (state, {payload} : PayloadAction<ILogItem>) => {
state.logArray.push(payload);
}
}
})
export const { addLog } = loggerSlice.actions;
export const loggerReducer = loggerSlice.reducer;
// components / BoardList / SideForm / SideForm.css.ts
import { vars } from '../../../App.css';
import { style } from '@vanilla-extract/css';
export const sideForm = style({
display: "flex",
alignItems : "center",
marginLeft : "auto"
})
export const input = style({
padding: vars.spacing.small,
fontSize : vars.fontSizing.T4,
minHeight : 30
})
export const icon = style({
color : vars.color.brightText,
fontSize : vars.fontSizing.T2,
marginLeft : vars.spacing.medium,
cursor : "pinter",
":hover" : {
opacity: 0.8
}
})
// App.tsx
import { useState } from 'react'
import { appContainer, board, buttons } from './App.css'
import BoardList from './components/BoardList/BoardList'
import ListsContainer from './components/ListsContainer/ListsContainer';
import { useTypedSelector } from './hooks/redux';
function App() {
const [activeBoardId, setActiveBoardId] = useState('board-0');
const boards = useTypedSelector(state => state.boards.boardArray);
const getActiveBoard = boards.filter(board => board.boardId === activeBoardId)[0];
const lists = getActiveBoard.lists;
return (
<div className={appContainer}>
<BoardList activeBoardId={activeBoardId} setActiveBoardId={setActiveBoardId}/>
<div className={board}>
<ListsContainer lists={lists} boardId={getActiveBoard.boardId}/>
</div>
<div className={buttons}>
<button>
이 게시판 삭제하기
</button>
<button>
</button>
</div>
</div>
)
}
export default App
// components / ListContainer / ListContainer.tsx
import React, { FC } from 'react'
import { IList } from '../../types';
import List from '../List/List';
import ActionButton from '../ActionButton/ActionButton';
import { listsContainer } from './ListsContainer.css';
type TListContainerProps = {
boardId : string;
lists : IList[];
}
const ListsContainer: FC<TListContainerProps>= ({
lists,
boardId
}) => {
return (
<div className={listsContainer}>
{
lists.map(list => (
<List key={list.listId} list={list} boardId={boardId}/>
))
}
<ActionButton />
</div>
)
}
export default ListsContainer
// components / List / List.tsx
import React from 'react'
const List = ({
list,
boardId
}) => {
return (
<div>List</div>
)
}
export default List
// components / ListContainer / ListContainer.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from '../../App.css';
export const listsContainer = style({
height: "max-content",
display: "flex",
flexWrap : "wrap",
rowGap : vars.spacing.listSpacing,
margin : vars.spacing.listSpacing
})
// components / List / List.tsx
import React, { FC } from 'react'
import { GrSubtract } from 'react-icons/gr'
import Task from '../Task/Task'
import ActionButton from '../ActionButton/ActionButton'
import { IList, ITask } from '../../types'
import { useTypedDispatch } from '../../hooks/redux'
import { deleteList, setModalActive } from '../../store/slices/boardsSlice'
import { addLog } from '../../store/slices/loggerSlice'
import { v4 as uuidv4} from 'uuid'
import { setModalData } from '../../store/slices/modalSlice'
import { deleteButton, header, listWrapper, name } from './List.css'
type TListProps = {
boardId : string;
list : IList;
}
const List: FC<TListProps>= ({
list,
boardId
}) => {
const dispatch = useTypedDispatch();
const handleListDelete = (listId : string) => {
dispatch(deleteList({boardId, listId}));
dispatch(
addLog({
logId: uuidv4(),
logMessage : `리스트 삭제하기: ${list.listName}`,
logAuthor : "User",
logTimeStamp : String(Date.now())
})
)
}
const handleTaskChange = (
boardId : string,
listId: string,
taskId: string,
task: ITask
) => {
dispatch(setModalData({boardId, listId, task}));
dispatch(setModalActive(true));
}
return (
<div className={listWrapper}>
<div className={header}>
<div className={name}>{list.listName}</div>
<GrSubtract className={deleteButton} onClick={() => handleListDelete(list.listId)}/>
</div>
{
list.tasks.map((task, index) => (
<div
onClick={() => handleTaskChange(boardId, list.listId, task.taskId, task)}
key={task.taskId}
>
<Task
taskName={task.taskName}
taskDescription={task.taskDescription}
boardId={boardId}
id={task.taskId}
index={index}
/>
</div>))
}
<ActionButton />
</div>
)
}
export default List
// store / slices / boardsSlice.ts
... 생략 ...
type TDeleteListAction = {
boardId: string;
listId: string;
}
... 생략 ,,,
const boardsSlice = createSlice({
name: 'boards',
initialState,
reducers : {
addBoard : (state, {payload} : PayloadAction<TAddBoardAction>) => {
state.boardArray.push(payload.board); // immer라는 라이브러리 사용해서 불변성 지키지 않아도됨
},
deleteList : (state, {payload} : PayloadAction<TDeleteListAction>) => {
state.boardArray = state.boardArray.map(
board => board.boardId === payload.boardId ? {...board, lists : board.lists.filter(
list => list.listId !== payload.listId
)
}
:
board
)
},
setModalActive : (state, {payload}: PayloadAction<boolean>) => {
state.modalActive = payload;
}
}
})
export const {addBoard, deleteList, setModalActive} = boardsSlice.actions;
export const boardsReducer = boardsSlice.reducer;
// store / slices / modalSice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ITask } from '../../types';
type TModalState = {
boardId: string;
listId: string;
task: ITask;
}
type TSetModalDataAction = {
boardId : string;
listId : string;
task: ITask;
}
const initialState : TModalState = {
boardId: "board-0",
listId: "list-0",
task: {
taskId: "task-0",
taskName: "task 0",
taskDescription: "task description",
taskOwner: "Lina"
}
};
const modalSlice = createSlice({
name: 'modal',
initialState,
reducers: {
setModalData : (state, {payload} : PayloadAction<TSetModalDataAction>) => {
state.boardId = payload.boardId;
state.listId = payload.listId;
state.task = payload.task;
}
}
});
export const { setModalData } = modalSlice.actions;
export const modalReducer = modalSlice.reducer;
// components / List / List.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from '../../App.css';
export const listWrapper = style({
display : "flex",
flexDirection : "column",
marginRight : vars.spacing.listSpacing,
padding : vars.spacing.big2,
minWidth : vars.minWidth.list,
width : "max-content",
height : "max-content",
borderRadius : 10,
backgroundColor : vars.color.list
})
export const name = style({
fontSize : vars.fontSizing.T3,
marginBottom : vars.spacing.big2
})
export const header = style({
display : "flex",
alignItems : "center"
})
export const deleteButton = style({
padding : vars.spacing.small,
borderRadius : 20,
fontSize : vars.fontSizing.T2,
marginLeft : "auto",
marginTop : "-15px",
marginRight : "5px",
cursor : "pointer",
":hover" : {
backgroundColor : vars.color.task,
boxShadow: vars.shadow.basic,
opacity: 0.8
}
})
// components / Task / Task.tsx
import React, { FC } from 'react'
import { container, description, title } from './Task.css';
type TTaskProps = {
index: number;
id: string;
boardId: string;
taskName: string;
taskDescription: string;
}
const Task: FC<TTaskProps> = ({
index,
id,
boardId,
taskName,
taskDescription
}) => {
return (
<div className={container}>
<div className={title}>{taskName}</div>
<div className={description}>{taskDescription}</div>
</div>
)
}
export default Task
// components / Task / Task.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from '../../App.css';
export const container = style({
display: "flex",
flexDirection : "column",
padding: vars.spacing.medium,
backgroundColor : vars.color.task,
borderRadius : 10,
marginBottom : vars.spacing.big2,
boxShadow : vars.shadow.basic,
cursor : "pointer",
":hover" : {
backgroundColor : vars.color.taskHover,
transform : "scale(1.03)",
}
})
export const title = style({
fontSize : vars.fontSizing.T4,
fontWeight : "bold",
marginBottom : vars.spacing.small
})
export const description = style({
fontSize : vars.fontSizing.P1
})
// components / List / List.tsx
... 생략 ...
<ActionButton
boardId={boardId}
listId={list.listId}
/>
</div>
)
}
export default List
// components / ListContainer / ListContainer.tsx
... 생략 ...
<ActionButton
boardId={boardId}
listId={""}
list
/>
</div>
)
}
export default ListsContainer
// components / ActionButton / ActionButton.tsx
import React, { FC, useState } from 'react'
import DropDownForm from './DropDownForm/DropDownForm';
import { IoIosAdd } from 'react-icons/io';
import { listButton, taskButton } from './ActionButton.css';
type TActionButtonProps = {
boardId: string;
listId: string;
list?: boolean;
}
const ActionButton: FC<TActionButtonProps> = ({
boardId,
listId,
list
}) => {
const [isFormOpen, setIsFormOpen] = useState(false);
const buttonText = list ? "새로운 리스트 등록" : "새로운 일 등록";
return isFormOpen ? (
<DropDownForm
setIsFormOpen={setIsFormOpen}
list={list ? true : false}
listId={listId}
boardId={boardId}
/>
)
:
(<div
className={list ? listButton : taskButton}
onClick={()=> setIsFormOpen(true)}
>
<IoIosAdd />
<p>{buttonText}</p>
</div>)
}
export default ActionButton
// components / ActionButton / ActionButton.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from '../../App.css';
export const taskButton = style({
display : "flex",
alignItems : "center",
height : "max-content",
borderRadius : 4,
marginTop : vars.spacing.small,
fontSize : vars.fontSizing.T3,
padding : vars.spacing.medium,
cursor : "pointer",
":hover" : {
backgroundColor : vars.color.secondaryDarkTextHover
}
})
export const listButton = style({
display : "flex",
alignItems : "center",
height : "max-content",
borderRadius : 4,
minWidth : vars.minWidth.list,
marginTop : vars.spacing.small,
color : vars.color.brightText,
fontSize : vars.fontSizing.T3,
backgroundColor : vars.color.mainFaded,
paddingTop : vars.spacing.small,
paddingBottom : vars.spacing.small,
paddingRight : vars.spacing.big2,
paddingLeft : vars.spacing.big2,
cursor : "pointer",
":hover" : {
backgroundColor : vars.color.mainFadedBright
}
})
// components / DropDownForm / DropDownForm.tsx
import React, { ChangeEvent, FC, useState } from 'react'
import { FiX } from 'react-icons/fi';
import { useTypedDispatch } from '../../../hooks/redux';
import { addList, addTask } from '../../../store/slices/boardsSlice';
import { v4 as uuidv4 } from 'uuid';
import { addLog } from '../../../store/slices/loggerSlice';
import { button, buttons, close, input, listForm, taskForm } from './DropDownForm.css';
type TDropDownFromProps = {
boardId : string;
listId : string;
setIsFormOpen : React.Dispatch<React.SetStateAction<boolean>>;
list?: boolean;
}
const DropDownForm: FC<TDropDownFromProps> = ({
boardId,
list,
listId,
setIsFormOpen
}) => {
const dispatch = useTypedDispatch();
const [text ,setText] = useState("");
const formPlaceholder = list ? "리스트의 제목을 입력하세요." : "일의 제목을 입력하세요.";
const buttonTitle = list ? "리스트 추가하기" : "일 추가하기";
const handleTextChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
}
const handleButtonClick = () => {
if(text) {
if(list) {
dispatch(
addList({
boardId,
list: {listId: uuidv4(), listName: text, tasks: []}
})
);
dispatch(
addLog({
logId: uuidv4(),
logMessage : `리스트 생성하기: ${text}`,
logAuthor : "User",
logTimeStamp : String(Date.now())
})
)
} else {
dispatch(
addTask({
boardId,
listId,
task: {
taskId : uuidv4(),
taskName : text,
taskDescription : "",
taskOwner : "User"
}
})
);
dispatch(
addLog({
logId : uuidv4(),
logMessage : `일 생성하기: ${text}`,
logAuthor : "User",
logTimeStamp : String(Date.now())
})
)
}
}
}
return (
<div className={list ? listForm : taskForm}>
<textarea
className={input}
value={text}
onChange={handleTextChange}
onBlur={() => setIsFormOpen(false)}
autoFocus
placeholder={formPlaceholder}
/>
<div className={buttons}>
<button className={button} onMouseDown={handleButtonClick}>
{buttonTitle}
</button>
<FiX className={close}/>
</div>
</div>
)
}
export default DropDownForm
// store / slices / boardsSlice.ts
... 생략 ...
type TAddListAction = {
boardId: string;
list: IList;
}
type TAddTaskAction = {
boardId: string;
listId: string;
task: ITask;
}
... 생략 ...
const boardsSlice = createSlice({
name: 'boards',
initialState,
reducers : {
... 생략 ...
addList: (state, {payload}: PayloadAction<TAddListAction>) => {
state.boardArray.map( board => board.boardId === payload.boardId ?
{...board, list: board.lists.push(payload.list)}
:
board
)
},
addTask: (state, {payload}: PayloadAction<TAddTaskAction>) => {
state.boardArray.map( board => board.boardId === payload.boardId ?
{
...board,
lists: board.lists.map(list => list.listId === payload.listId ?
{
...list,
tasks : list.tasks.push(payload.task)
}
:
list
)
}
:
board
)
},
... 생략 ...
export const {addBoard, deleteList, setModalActive, addList, addTask} = boardsSlice.actions;
export const boardsReducer = boardsSlice.reducer;
// components / DropDownForm / DropDownForm.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from '../../../App.css';
export const taskForm = style({
display : "flex",
flexDirection : "column",
height : "max-content",
borderRadius : 4,
marginTop : vars.spacing.small,
fontSize : vars.fontSizing.T3,
padding: vars.spacing.medium
})
export const listForm = style({
display : "flex",
flexDirection : "column",
marginRight : vars.spacing.listSpacing,
padding : vars.spacing.big2,
width : "max-content",
height : "max-content",
borderRadius : 20,
backgroundColor : vars.color.list
})
export const input = style({
padding : vars.spacing.medium,
fontSize : vars.fontSizing.P1,
minHeight : 60,
marginBottom : vars.spacing.medium,
border : "none",
boxShadow : vars.shadow.basic,
borderRadius : 4,
resize : "none",
overflow : "hidden",
wordWrap : "break-word"
})
export const button = style({
width: 150,
color : vars.color.brightText,
padding : vars.spacing.medium,
fontSize : vars.fontSizing.T3,
backgroundColor : vars.color.mainDarker,
border : "none",
cursor : "pointer",
":hover" : {
backgroundColor : vars.color.mainFaded
}
})
export const buttons = style({
display : "flex",
flexDirection : "row",
alignItems : "center"
})
export const close = style({
marginLeft : vars.spacing.big2,
fontSize : vars.fontSizing.T1,
opacity : 0.5,
":hover" : {
opacity : 0.7
}
})