@@ -202,12 +202,12 @@ export default function DropDownFilter({ filterTitle, filterItems, dispatchTypeB
};
const itemByType = {
- issue: [titleItem, ...defaultTypeItems()],
+ issue: [titleItem, ...defaultTypeItems(false)],
assignee: [titleItem, clearTypeItem, ...imageTypeItems()],
label: [titleItem, clearTypeItem, ...labelTypeItems()],
- milestone: [titleItem, clearTypeItem, ...defaultTypeItems()],
+ milestone: [titleItem, clearTypeItem, ...defaultTypeItems(false)],
author: [titleItem, ...imageTypeItems()],
- state: [titleItem, , ...defaultTypeItems()],
+ state: [titleItem, , ...defaultTypeItems(true)],
};
const items = itemByType[filterTitle];
diff --git a/FE/src/components/issues/IssueDetail.jsx b/FE/src/components/issues/IssueDetail.jsx
index 52cb88c8f..ee59540f0 100644
--- a/FE/src/components/issues/IssueDetail.jsx
+++ b/FE/src/components/issues/IssueDetail.jsx
@@ -67,7 +67,8 @@ export default function IssueDetail() {
// const remainFileIds = remainFiles.map((file) => file.id);
const remainFileIds = fileMeta.map((file) => file.id).filter((e) => e !== '');
- createIssueComment({ writerId: getUserId(), content: newCommentArea, fileId: remainFileIds[0] });
+
+ createIssueComment({ writerId: getUserId(), content: newCommentArea, fileId: remainFileIds?.[0] });
setNewCommentArea('');
setFileMeta(initFileDatas);
};
@@ -156,7 +157,6 @@ export default function IssueDetail() {
$fileOnChange={handleFileChange}
$onFocus={handleFocus}
$onBlur={handleBlur}
- setFileMeta={setFileMeta}
/>
diff --git a/FE/src/components/issues/IssueDetailSidebar.jsx b/FE/src/components/issues/IssueDetailSidebar.jsx
index 7f83a5457..863ef376d 100644
--- a/FE/src/components/issues/IssueDetailSidebar.jsx
+++ b/FE/src/components/issues/IssueDetailSidebar.jsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import styled from 'styled-components';
import { FlexRow } from '../../styles/theme';
import { Popconfirm, message } from 'antd';
@@ -7,6 +7,13 @@ import { useNavigate } from 'react-router-dom';
import OptionSidebar from './OptionSidebar';
import { IconTrash } from '../../assets/icons/IconTrash';
+const initCheckedData = {
+ assignee: [],
+ label: [],
+ milestone: '',
+};
+
+
export default function IssueDetailSidebar({ milestone, assignees, labels, issueId, isEditable = false }) {
const navigate = useNavigate();
const onSuccess = () => {
@@ -14,19 +21,38 @@ export default function IssueDetailSidebar({ milestone, assignees, labels, issue
navigate('/');
};
const { mutate: deleteIssue } = useDeleteIssue(issueId, onSuccess);
+ const [checkedDatas, setCheckedDatas] = useState(initCheckedData);
const deleteConfirm = () => deleteIssue();
return (
-
+
담당자
-
+
레이블
-
+
마일스톤
diff --git a/FE/src/components/issues/Main.jsx b/FE/src/components/issues/Main.jsx
index d34316110..1cc4164d4 100644
--- a/FE/src/components/issues/Main.jsx
+++ b/FE/src/components/issues/Main.jsx
@@ -8,10 +8,14 @@ import { useFilterContext } from '../../context/FilterContext';
import NavStateType from './NavStateType';
import NavFilterType from './NavFilterType';
import { MainContainer } from '../../styles/theme';
+import { TagsOutlined, PlusOutlined } from '@ant-design/icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useLabelsFilter, useMembersFilter, useMilestonesFilter } from '../../hooks/useFiltersData';
import { usefilteredIssueData } from '../../hooks/usefilteredIssueData';
import ClipLoader from 'react-spinners/ClipLoader';
+import { useLabelMilestoneCountData } from '../../hooks/useLabelData';
+import { CustomButton } from '../../assets/CustomButton';
+import { IconMilestone } from '../../assets/icons/IconMilestone';
const stateModifyFilters = [{ title: '선택한 이슈' }];
@@ -66,11 +70,16 @@ export default function Main() {
const [checkedItems, setCheckedItems] = useState([]);
const [filterItemsByType, setFilterItemsByType] = useState(initFilterItems);
const [issueDatas, setIssueDatas] = useState(initIssueDatas);
+ const [hasFetched, setHasFetched] = useState(initFetched);
+ const [labelCount, setLabelCount] = useState(0);
+ const [milestoneCount, setMilestoneCount] = useState(0);
+ const { data: countData } = useLabelMilestoneCountData();
const { data: issueList, isLoading: issueListIsLoading } = usefilteredIssueData();
- const { data: labelsFilter, isLoading: labelsFilterIsLoading } = useLabelsFilter({});
- const { data: milestonesFilter, isLoading: milestonesFilterIsLoading } = useMilestonesFilter({});
- const { data: membersFilter, isLoading: membersFilterIsLoading } = useMembersFilter({});
+ const { data: labelsFilter, isLoading: labelsFilterIsLoading } = useLabelsFilter({ enabled: hasFetched.label });
+ const { data: milestonesFilter, isLoading: milestonesFilterIsLoading } = useMilestonesFilter({ enabled: hasFetched.milestone });
+ const { data: membersFilter, isLoading: membersFilterIsLoading } = useMembersFilter({ enabled: hasFetched.assignee });
+
const clearFilter = () => dispatch({ type: 'SET_CLEAR_FILTER', payload: '' });
@@ -111,8 +120,6 @@ export default function Main() {
return checkedItems.includes(key);
};
- const [hasFetched, setHasFetched] = useState(initFetched);
-
const setterFechedByType = {
assignee: () => setHasFetched((prev) => ({ ...prev, assignee: true })),
label: () => setHasFetched((prev) => ({ ...prev, label: true })),
@@ -190,6 +197,18 @@ export default function Main() {
setIssueDatas((prev) => ({ ...prev, list: newIssueList }));
}, [issueList]);
+ useEffect(() => {
+ if (!countData) return;
+ setLabelCount(countData.labelCount.count);
+ setMilestoneCount(countData.milestoneCount.isOpened + countData.milestoneCount.isClosed);
+ }, [countData]);
+
+ // useEffect(() => {
+ // const url = new URL(window.location.href);
+ // const authorizationCode = url.searchParams.get('code');
+ // console.log(authorizationCode); //인증 코드
+ // }, []);
+
return (
@@ -211,11 +230,19 @@ export default function Main() {
- navigate('/labels')}>레이블()
- navigate('/milestones')}>마일스톤()
- navigate('/issues/new')} type="primary">
- + 이슈작성
-
+ navigate('/labels')}>
+
+ 레이블({labelCount})
+
+ navigate('/milestones')}>
+
+ 마일스톤({milestoneCount})
+
+
+ navigate('/issues/new')} size={'large'} isDisabled={false}>
+
+ 이슈작성
+
@@ -276,6 +303,29 @@ export default function Main() {
);
}
+
+const StyledNewIssueBtn = styled(CustomButton)`
+ width: 120px;
+ font-size: 15px;
+ font-weight: 500;
+ color: #fff;
+`;
+
+const StyledMilestoneBtn = styled(CustomButton)`
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ width: 130px;
+ font-size: 15px;
+ font-weight: 500;
+`;
+const StyledLabelBtn = styled(CustomButton)`
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ width: 130px;
+ font-size: 15px;
+ font-weight: 500;
+`;
+
const StyledLoader = styled.div`
height: 100px;
align-content: center;
@@ -404,6 +454,14 @@ const StyledNav = styled.nav`
}
`;
-const NavBtnContainer = styled.div`
- width: 380px;
+const NavBtnContainer = styled(FlexRow)`
+ justify-content: end;
+ margin-left: 10px;
+
+ & .createBtn {
+ margin-left: 10px;
+ }
+ button {
+ font-size: 16px;
+ }
`;
diff --git a/FE/src/components/issues/NewIssue.jsx b/FE/src/components/issues/NewIssue.jsx
index 7e7d6321b..9b19fabd1 100644
--- a/FE/src/components/issues/NewIssue.jsx
+++ b/FE/src/components/issues/NewIssue.jsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { FlexCol, FlexRow, IndexContainer, MainContainer, StyledInput } from '../../styles/theme';
import Header from '../header/Header';
import styled from 'styled-components';
@@ -6,13 +6,37 @@ import CustomTextEditor from '../../assets/CustomTextEditor';
import { CustomButton } from '../../assets/CustomButton';
import { IconPlus } from '../../assets/icons/IconPlus';
import IssueDetailSidebar from './IssueDetailSidebar';
+import { CustomProfile } from '../../assets/CustomProfile';
+import { getUserId, getUserImg } from '../../utils/userUtils';
+import OptionSidebar from './OptionSidebar';
+import { fetchUploadFile } from '../../api/fetchIssueData';
+import { useNavigate } from 'react-router-dom';
+import { useCreateNewIssue } from '../../hooks/useIssueDetailData';
+import { message } from 'antd';
+
+const initFileDatas = [{ id: '', url: '', uploadName: '' }]; //*[{id:String, url:String, uploadName:String}]
+const initCheckedData = {
+ assignee: [],
+ label: [],
+ milestone: '',
+};
export default function NewIssue() {
+ const navigate = useNavigate();
+
+ const onSuccess = (issueId) => {
+ message.success('생성되었습니다.');
+ navigate(`/issues/${issueId}`);
+ };
+ const { mutate: createNewIssue } = useCreateNewIssue(onSuccess);
+ const [checkedDatas, setCheckedDatas] = useState(initCheckedData);
+
//TODO: Ref로 작업
const [newTitle, setNewTitle] = useState('');
const [newCommentArea, setNewCommentArea] = useState('');
const [isNewCommentFocused, setIsNewCommentFocused] = useState(false);
const [isNewCommetDisabled, setIsNewCommetDisabled] = useState(true);
+ const [fileMeta, setFileMeta] = useState(initFileDatas); //*[{id:String, url:String}] //현재는 파일"1개만" 등록가능
const handleCommentChange = ({ target }) => {
const { value } = target;
setNewCommentArea(value);
@@ -24,46 +48,112 @@ export default function NewIssue() {
const handleFocus = () => setIsNewCommentFocused(true);
const handleBlur = () => setIsNewCommentFocused(false);
+ const handleFileChange = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const formData = new FormData();
+ formData.append('file', file);
+
+ try {
+ const responseData = await fetchUploadFile(formData);
+
+ if (responseData && responseData.url && responseData.uploadName && responseData.id) {
+ setNewCommentArea((prev) => `${prev}\n![${responseData.uploadName}](${responseData.url})`);
+ setFileMeta((prev) => [...prev, { id: String(responseData.id), url: responseData.url, uploadName: responseData.uploadName }]);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ };
//TODO: fileId
- const submitNewComment = () => {
- // createIssueComment({ writerId: getUserId(), content: newCommentArea });
- setNewCommentArea('');
+ const submitNewIssue = () => {
+ const remainFileIds = fileMeta.map((file) => file.id).filter((e) => e !== '');
+ createNewIssue({
+ title: newTitle,
+ content: newCommentArea,
+ authorId: getUserId(),
+ milestoneId: checkedDatas.milestone,
+ fileId: remainFileIds?.[0],
+ labelIds: checkedDatas.label,
+ assigneeIds: checkedDatas.assignee,
+ });
};
+ useEffect(() => {
+ if (newTitle.length > 0 && newCommentArea.length > 0) setIsNewCommetDisabled(false);
+ else setIsNewCommetDisabled(true);
+ }, [newCommentArea]);
+
return (
+
- 🚧새로운 이슈 작성🚧
+ 새로운 이슈 작성
-
+
+
+
+
+
-
+
+ 제목
+
+
-
+
-
-
-
- 코멘트 작성
-
-
- {/* */}
+
+
+ 담당자
+
+
+ 레이블
+
+
+ 마일스톤
+
+
+
-
+
완료
@@ -71,13 +161,24 @@ export default function NewIssue() {
);
}
+const SidebarContainer = styled.div`
+ flex-basis: 25%;
+ min-width: 200px;
+ min-height: 500px;
+ border: 1px solid ${(props) => props.theme.borderColor};
+ border-radius: 20px;
+ /* background-color: red; */
+`;
+
const StyledTitle = styled(FlexRow)`
position: relative;
+ margin-bottom: 10px;
+ width: 100%;
`;
const MainBtnContainer = styled(FlexRow)`
justify-content: end;
width: 100%;
- margin-bottom: 20px;
+ margin: 20px 0;
`;
const Content = styled.div`
/* display: flex;
@@ -108,10 +209,15 @@ const PlaceholdText = styled.span`
font-size: 13px;
`;
+const StyledProfile = styled(FlexCol)`
+ margin-right: 2cap;
+`;
+
const StyledComments = styled(FlexCol)`
/* background-color: azure; */
- flex-basis: 70%;
- margin-right: 30px;
+ flex-basis: 65%;
+ /* margin-right: 30px; */
+ margin-right: 20px;
min-width: 700px;
min-height: 200px;
/* display: flex;
@@ -125,7 +231,7 @@ const ContentsContainer = styled.div`
justify-content: space-between;
margin-top: 15px;
width: 100%;
- height: 700px; //???
+ /* height: 700px; //???
/* background-color: aquamarine; */
`;
const StyledMainContainer = styled(MainContainer)`
@@ -141,10 +247,22 @@ const HeaderShow = styled.div`
}
`;
const StyledDetailContainer = styled(IndexContainer)`
- .title {
+ & .title {
+ position: relative;
+ }
+ & .title::after {
+ content: '';
+ position: absolute;
+ width: 100%;
+ height: 1px;
+ left: 0;
+ bottom: 0;
+ background-color: ${(props) => props.theme.borderColor};
+ }
+ & .contents {
position: relative;
}
- .title::after {
+ & .contents::after {
content: '';
position: absolute;
width: 100%;
diff --git a/FE/src/components/issues/OptionSidebar.jsx b/FE/src/components/issues/OptionSidebar.jsx
index 22449488b..708bd0e51 100644
--- a/FE/src/components/issues/OptionSidebar.jsx
+++ b/FE/src/components/issues/OptionSidebar.jsx
@@ -15,6 +15,9 @@ const initActivePopup = {
label: false,
milestone: false,
};
+
+
+export default function OptionSidebar({ filterName, filterData, issueId, children, isNew = false, checkedDatas, setCheckedDatas }) {
const initCheckedData = {
assignee: [],
label: [],
@@ -25,9 +28,6 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
const openIssueCount = filterData?.openIssueCount ?? 0;
const closedIssueCount = filterData?.closedIssueCount ?? 0;
const popupRef = useRef(null);
-
- const [checkedDatas, setCheckedDatas] = useState(initCheckedData);
-
const [isActivePopup, setIsActivePopup] = useState(initActivePopup);
const [isAssigneeFetchPossible, setIsAssigneeFetchPossible] = useState(false);
const [isLabelFetchPossible, setIsLabelFetchPossible] = useState(false);
@@ -64,7 +64,6 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
};
const handleMouseEnter = (type) => {
- // setIsActivePopup(true);
toggleEnableByType[type]();
};
@@ -77,6 +76,11 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
setCheckedDatas((prev) => ({ ...prev, [type]: checkedDatas }));
};
+ const getCorrectAssignee = (checkedId) => assigneesData.filter(({ id }) => id === checkedId)?.[0];
+ const getCorrectLabel = (checkedId) => labelsData.labels.filter(({ id }) => String(id) === checkedId)?.[0];
+ const getCorrectMilestone = (checkedId) =>
+ [...openMilestonesData.milestoneDetailDtos, ...closedMilestonesData.milestoneDetailDtos].filter(({ id }) => String(id) === checkedId)?.[0];
+
useEffect(() => {
if (!filterData) return;
@@ -89,7 +93,6 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
}, [filterData]);
useEffect(() => {
- // 팝업 외부 클릭을 감지
const handleClickOutside = ({ target }) => {
if (popupRef.current && !popupRef.current.contains(target)) {
setIsActivePopup({ ...initActivePopup });
@@ -115,16 +118,42 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
{filterName === 'assignee' &&
- filterData.map(({ id, imgUrl }) => (
+ isNew &&
+ checkedDatas.assignee.map((id) => {
+ const correctAssignee = getCorrectAssignee(id);
+ return (
+
+ {correctAssignee?.imgUrl ? : }
+ {correctAssignee?.id}
+
+ );
+ })}
+ {filterName === 'assignee' &&
+ !isNew &&
+ filterData?.map(({ id, imgUrl }) => (
{imgUrl ? : }
{id}
))}
- {filterName === 'label' && (
+
+ {filterName === 'label' && isNew && (
- {filterData.map(({ id, name, description, textColor, bgColor }) => (
+ {checkedDatas.label.map((id) => {
+ const correctLabel = getCorrectLabel(id);
+ return (
+
+ {correctLabel.name}
+
+ );
+ })}
+
+ )}
+ {filterName === 'label' && !isNew && (
+
+ {filterData?.map(({ id, name, description, textColor, bgColor }) => (
+
{name}
@@ -132,7 +161,16 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
)}
- {filterName === 'milestone' && (
+
+ {filterName === 'milestone' && isNew && openMilestonesData && closedMilestonesData && (
+ <>
+
+
+
+ {getCorrectMilestone(checkedDatas.milestone)?.name}
+ >
+ )}
+ {filterName === 'milestone' && !isNew && (
<>
@@ -153,6 +191,8 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
checkedDatas={checkedDatas?.assignee}
setCheckedDatas={setCheckedDatas}
issueId={issueId}
+ isNew={isNew}
+
/>
)}
{filterName === 'label' && labelsData && (
@@ -163,6 +203,8 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
checkedDatas={checkedDatas?.label}
setCheckedDatas={setCheckedDatas}
issueId={issueId}
+ isNew={isNew}
+
/>
)}
{filterName === 'milestone' && openMilestonesData && closedMilestonesData && (
@@ -173,6 +215,8 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre
checkedDatas={checkedDatas?.milestone}
setCheckedDatas={setCheckedDatas}
issueId={issueId}
+ isNew={isNew}
+
/>
)}
diff --git a/FE/src/components/issues/OptionSidebarContents.jsx b/FE/src/components/issues/OptionSidebarContents.jsx
index 768cee938..5fbea697d 100644
--- a/FE/src/components/issues/OptionSidebarContents.jsx
+++ b/FE/src/components/issues/OptionSidebarContents.jsx
@@ -7,7 +7,9 @@ import { Checkbox, Radio } from 'antd';
import { useModifyIssueAssignees, useModifyIssueLabels, useModifyIssueMilestone } from '../../hooks/useIssueDetailData';
import CustomNoProfile from '../../assets/CustomNoProfile';
-export default function OptionSidebarContents({ contents, filterName, filterData, checkedDatas, setCheckedDatas, issueId }) {
+
+export default function OptionSidebarContents({ contents, filterName, filterData, checkedDatas, setCheckedDatas, issueId, isNew }) {
+
const { mutate: modifyIssueLabels } = useModifyIssueLabels(String(issueId)); //labelIds
const { mutate: modifyIssueAssignees } = useModifyIssueAssignees(String(issueId)); //assigneeIds
const { mutate: modifyIssueMilestone } = useModifyIssueMilestone(String(issueId)); //milestoneId
@@ -60,7 +62,9 @@ export default function OptionSidebarContents({ contents, filterName, filterData
useEffect(() => {
return () => {
- if (isModifyed(filterData, checkedDatas)) modifyActionByType[filterName]();
+
+ if (!isNew && isModifyed(filterData, checkedDatas)) modifyActionByType[filterName]();
+
};
}, [checkedDatas]);
diff --git a/FE/src/components/labels/LabelEditor.jsx b/FE/src/components/labels/LabelEditor.jsx
new file mode 100644
index 000000000..f0f946563
--- /dev/null
+++ b/FE/src/components/labels/LabelEditor.jsx
@@ -0,0 +1,367 @@
+import React, { useEffect, useRef, useState } from 'react';
+import styled from 'styled-components';
+import { PlusOutlined, RedoOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons';
+import { CustomLabelBadge } from '../../assets/CustomLabelBadge';
+import { FlexCol, FlexRow, StyledInput } from '../../styles/theme';
+import { CustomButton } from '../../assets/CustomButton';
+import { Radio, message } from 'antd';
+import validateColor from 'validate-color';
+import { useCreateNewLabel, useModifyLabel } from '../../hooks/useLabelData';
+import { IconEdit } from '../../assets/icons/IconEdit';
+
+export default function LabelEditor({ isNew = true, togglePlusLabelState, toggleEditLabelState, id, bgColor, textColor, name, description }) {
+ const clearInputForm = () => {
+ setNewBgColor('#');
+ setNewBgColorValue('#');
+ setIsNewBgColorValidate(false);
+ setIsNewLabelDisabled(false);
+ setNewLabelName('');
+ setNewFontColor('');
+ discriptionRef.current.value = '';
+ };
+ const onSuccessCallback = () => {
+ clearInputForm();
+ if (!isNew) toggleEditLabelState();
+ message.success(`레이블이 ${isNew ? '생성' : '수정'}되었습니다.`);
+ };
+ const onErrorCallback = () => {
+ message.error(`수정 실패! 다시 시도해주세요.`);
+ };
+ const { mutate: createLabel } = useCreateNewLabel({
+ onSuccessCallback,
+ });
+ const { mutate: modifyLabel } = useModifyLabel({
+ onSuccessCallback,
+ onErrorCallback,
+ });
+
+ const [isPopupVisible, setIsPopupVisible] = useState(false);
+ const [isNewLabelDisabled, setIsNewLabelDisabled] = useState(true);
+ const [isEditLabelDisabled, setIsEditLabelDisabled] = useState(true);
+ const [newLabelName, setNewLabelName] = useState('');
+
+ const [newBgColor, setNewBgColor] = useState('#');
+ const [newBgColorValue, setNewBgColorValue] = useState('#');
+ const [isNewBgColorValidate, setIsNewBgColorValidate] = useState(false);
+
+ const [newFontColor, setNewFontColor] = useState(''); // 'light' | 'dark'
+ const [editDiscriptionValue, setEditDiscriptionValue] = useState('');
+ const discriptionRef = useRef(null);
+ const popupRef = useRef(null);
+
+ const handleNameChange = ({ target }) => {
+ const { value } = target;
+ setNewLabelName(value);
+ };
+
+ const isValidateColor = (value) => {
+ return value && validateColor(value);
+ };
+
+ const handleBgColorChange = ({ target }) => {
+ const { value } = target;
+ setNewBgColorValue(value);
+
+ if (isValidateColor(value)) {
+ setNewBgColor(value);
+ setIsNewBgColorValidate(true);
+ } else {
+ setNewBgColor('');
+ setIsNewBgColorValidate(false);
+ }
+ };
+ const handleFontColorChange = (e) => {
+ const fontColor = e.currentTarget.dataset.id ?? '';
+ setNewFontColor(fontColor);
+ };
+
+ const togglePopup = () => setIsPopupVisible((prevState) => !prevState);
+
+ const submitEditLabel = () => {
+ modifyLabel({
+ name: newLabelName,
+ description: discriptionRef.current.value === undefined || discriptionRef.current.value === '' ? null : discriptionRef.current.value,
+ textColor: newFontColor === 'light' ? '#fff' : '#000',
+ bgColor: newBgColor,
+ labelId: id,
+ });
+ };
+
+ const submitLabel = () => {
+ createLabel({
+ name: newLabelName,
+ description: discriptionRef.current.value === undefined || discriptionRef.current.value === '' ? null : discriptionRef.current.value,
+ textColor: newFontColor === 'light' ? '#fff' : '#000',
+ bgColor: newBgColor,
+ });
+ };
+
+ const setRandomColors = () => {
+ const letters = '0123456789ABCDEF';
+ const randomColor = Array.from({ length: 6 }).reduce((acc, _, idx) => {
+ if (idx === 0) acc += '#';
+ return (acc += letters[Math.floor(Math.random() * 16)]);
+ }, '');
+
+ if (isValidateColor(randomColor)) {
+ setNewBgColor(randomColor);
+ setNewBgColorValue(randomColor);
+ setIsNewBgColorValidate(true);
+ }
+ };
+
+ const settingEditValues = (textColor, bgColor, name, description) => {
+ textColor === '#fff' ? setNewFontColor('light') : setNewFontColor('dark');
+ setNewBgColor(bgColor);
+ setNewBgColorValue(bgColor);
+ setNewLabelName(name);
+ discriptionRef.current.value = description;
+ setEditDiscriptionValue(description);
+ };
+
+ const handleEditDescriptionChange = () => {
+ if (isNew) return;
+ setEditDiscriptionValue(discriptionRef.current.value);
+ };
+
+ const settingEditButtonDisable = (newLabelName, newBgColorValue, newFontColor, editDiscriptionValue) => {
+ const convertTextColor = textColor === '#fff' ? 'light' : 'dark';
+ if (
+ newLabelName !== name ||
+ (newBgColorValue !== bgColor && isValidateColor(newBgColorValue)) ||
+ newFontColor !== convertTextColor ||
+ editDiscriptionValue !== description
+ )
+ setIsEditLabelDisabled(false);
+ else setIsEditLabelDisabled(true);
+ };
+
+ const settingNewButtonDisable = (newLabelName, newBgColor, newFontColor) => {
+ if (newLabelName && newBgColor && newFontColor && isNewBgColorValidate) setIsNewLabelDisabled(false);
+ else setIsNewLabelDisabled(true);
+ };
+
+ useEffect(() => {
+ if (isNew) return;
+ settingEditButtonDisable(newLabelName, newBgColorValue, newFontColor, editDiscriptionValue);
+ }, [newLabelName, newBgColorValue, newFontColor, editDiscriptionValue]);
+
+ useEffect(() => {
+ if (isNew) return;
+ settingEditValues(textColor, bgColor, name, description);
+ }, [textColor, bgColor, name, description]);
+
+ useEffect(() => {
+ settingNewButtonDisable(newLabelName, newBgColor, newFontColor);
+ }, [newLabelName, newBgColor, newFontColor]);
+
+ return (
+
+ {isNew ? '새로운 레이블 추가' : '레이블 편집'}
+
+
+
+
+ {newLabelName}
+
+
+
+
+
+ 이름
+
+
+
+ 설명(선택)
+
+
+
+
+ 배경 색상
+
+
+
+
+
+
+
+ {newFontColor ? (newFontColor === 'light' ? '밝은 색' : '어두운 색') : '텍스트 색상'}
+ {' '}
+
+ {isPopupVisible && (
+
+
+ 텍스트 색상
+
+ 밝은 색
+
+
+
+
+
+ 어두운 색
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ 취소
+
+
+
+ {isNew ? (
+ <>
+
+ '완료'
+ >
+ ) : (
+ <>
+
+ '편집 완료'
+ >
+ )}
+
+
+
+ );
+}
+const StyledList = styled.li`
+ display: flex;
+ justify-content: space-between;
+ padding: 5px 12px;
+ width: 100%;
+
+ & .itemTitle {
+ /* width: 80px; */
+ }
+ & .titleName {
+ margin-left: 10px;
+ }
+`;
+
+const StyledTextColorBtn = styled.span`
+ position: relative;
+ display: inline-block;
+ cursor: pointer;
+`;
+const PopupContainer = styled.div`
+ position: absolute;
+ min-width: 180px;
+ min-height: 65px;
+ border: 2px solid ${(props) => props.theme.borderColor};
+ border-radius: 20px;
+ background-color: ${(props) => props.theme.bgColorBody};
+ opacity: 90%;
+ z-index: 5;
+ & .title {
+ border-radius: 20px 20px 0 0;
+ background-color: ${(props) => props.theme.listHeaderColor};
+ }
+`;
+
+const NavBtnContainer = styled(FlexRow)`
+ justify-content: end;
+ & .createBtn {
+ margin-left: 10px;
+ }
+ button {
+ font-size: 16px;
+ }
+`;
+
+const StyledColorOption = styled(FlexRow)`
+ justify-content: flex-start;
+ & .backgroudColorOption {
+ margin-right: 20px;
+ }
+`;
+const StyledTitle = styled(FlexRow)`
+ position: relative;
+ /* background-color: red; */
+`;
+const RandomIcon = styled.span`
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ color: var(--secondary-color);
+ font-size: 13px;
+ cursor: pointer;
+`;
+const PlaceholdText = styled.span`
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ color: var(--secondary-color);
+ font-size: 13px;
+`;
+const ModifyInput = styled(StyledInput)`
+ width: 100%;
+ height: 100%;
+ padding: 10px 20px 10px 90px;
+ color: var(--secondary-color);
+`;
+
+const InputContainer = styled(FlexCol)`
+ justify-content: space-between;
+ width: 100%;
+ align-self: normal;
+ /* background-color: aquamarine; */
+
+ & ::placeholder {
+ font-size: 13px;
+ font-style: italic;
+ /* color: ${(props) => props.theme.fontColor}; */
+ }
+`;
+const Preview = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-width: 200px;
+ height: 150px;
+ flex-basis: 30%;
+ border-radius: 10px;
+ border: 1px solid;
+ border-color: ${(props) => (props.$isfocused ? 'var(--primary-color)' : props.theme.borderColor)};
+ margin-right: 20px;
+ background-color: white;
+`;
+
+const AddContents = styled(FlexRow)`
+ margin-bottom: 20px;
+ /* justify-content: space-between; */
+`;
+
+const AddTitle = styled.div`
+ width: 100%;
+ display: flex;
+ font-size: 20px;
+ font-weight: bold;
+ margin-bottom: 15px;
+`;
+
+const AddContainer = styled.div`
+ width: 100%;
+ border-radius: ${(props) => (props.$isNew ? '10px' : 0)};
+ border: 2px solid;
+ border-color: ${(props) => (props.$isNew ? 'var(--primary-color)' : props.theme.borderColor)};
+ color: ${(props) => props.theme.fontColor};
+ padding: 30px;
+ background-color: ${(props) => props.theme.bgColorBody};
+`;
diff --git a/FE/src/components/labels/LabelList.jsx b/FE/src/components/labels/LabelList.jsx
new file mode 100644
index 000000000..d03924d82
--- /dev/null
+++ b/FE/src/components/labels/LabelList.jsx
@@ -0,0 +1,104 @@
+import React, { useState } from 'react';
+import styled from 'styled-components';
+import { CustomLabelBadge } from '../../assets/CustomLabelBadge';
+import { EditOutlined } from '@ant-design/icons';
+import { IconTrash } from '../../assets/icons/IconTrash';
+import { FlexRow } from '../../styles/theme';
+import LabelEditor from './LabelEditor';
+import { Popconfirm, message } from 'antd';
+import { useDeleteLabel } from '../../hooks/useLabelData';
+
+export default function LabelList({ id, name, description, textColor, bgColor, isNoList = false }) {
+ const [isLabelEditState, setLabelEditState] = useState(false);
+ const toggleEditLabelState = () => setLabelEditState((prev) => !prev);
+
+ const onErrorCallback = () => message.error(`삭제 실패! 다시 시도해주세요.`);
+ const onSuccessCallback = () => message.success(`삭제 완료!`);
+ const { mutate: deleteLabel } = useDeleteLabel({
+ onSuccessCallback,
+ onErrorCallback,
+ });
+ const handleDeleteLabel = (labelId) => deleteLabel({ labelId });
+
+ return (
+ <>
+ {isNoList ? (
+
+ 등록된 레이블이 없습니다.
+
+ ) : isLabelEditState ? (
+
+ ) : (
+
+
+
+
+ {name}
+
+
+ {description || ''}
+
+
+
+
+
+ 편집
+
+
+ handleDeleteLabel(id)} okText="Yes" cancelText="No">
+
+
+ 삭제
+
+
+
+
+ )}
+ >
+ );
+}
+
+const StyledBtn = styled.div`
+ display: flex;
+ cursor: pointer;
+ color: ${(props) => props.theme.fontColor};
+ margin-left: 20px;
+`;
+const BadgeContainer = styled(FlexRow)`
+ min-width: 200px;
+ margin-right: 10px;
+`;
+const TitleContainer = styled(FlexRow)`
+ flex-basis: 80%;
+ justify-content: flex-start;
+`;
+
+const StyledBtnContainer = styled.div`
+ display: flex;
+`;
+
+const StyledDescription = styled.div``;
+
+const ListContainer = styled(FlexRow)`
+ min-height: 80px;
+ align-items: center;
+ justify-content: space-between;
+ border-top: 1px solid;
+ border-color: ${(props) => props.theme.borderColor};
+ padding: 0 30px;
+
+ & .noList {
+ width: 100%;
+ height: 100%;
+ align-content: center;
+ margin-top: 20px;
+ }
+`;
diff --git a/FE/src/components/labels/Labels.jsx b/FE/src/components/labels/Labels.jsx
deleted file mode 100644
index 56a2dedcd..000000000
--- a/FE/src/components/labels/Labels.jsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import React from 'react';
-
-export default function labels() {
- return 라벨 생성 페이지 입니다.
;
-}
diff --git a/FE/src/components/labels/Main.jsx b/FE/src/components/labels/Main.jsx
new file mode 100644
index 000000000..0b205eb48
--- /dev/null
+++ b/FE/src/components/labels/Main.jsx
@@ -0,0 +1,136 @@
+import React, { useEffect, useState } from 'react';
+import Header from '../header/Header';
+import { FlexCol, FlexRow, IndexContainer, MainContainer, StyledInput } from '../../styles/theme';
+import styled from 'styled-components';
+import { CustomButton } from '../../assets/CustomButton';
+import { IconMilestone } from '../../assets/icons/IconMilestone';
+import { TagsOutlined, PlusOutlined } from '@ant-design/icons';
+import LabelList from './LabelList';
+import LabelEditor from './LabelEditor';
+import { useNavigate } from 'react-router-dom';
+import { useLabelDetailData, useLabelMilestoneCountData } from '../../hooks/useLabelData';
+import ClipLoader from 'react-spinners/ClipLoader';
+
+export default function LabelMain() {
+ const naivgate = useNavigate();
+ const clickMileStone = () => naivgate('/milestones');
+ const clickLabel = () => naivgate('/labels');
+ const { data: labelData, isLoading: labelDataIsLoading } = useLabelDetailData();
+ const { data: countData } = useLabelMilestoneCountData();
+ const [isPlusLabelState, setIsPlusLabelState] = useState(false);
+ const [labelCount, setLabelCount] = useState(0);
+ const [milestoneCount, setMilestoneCount] = useState(0);
+
+ const togglePlusLabelState = () => setIsPlusLabelState((prev) => !prev);
+
+ useEffect(() => {
+ if (!countData) return;
+ setLabelCount(countData.labelCount.count);
+ setMilestoneCount(countData.milestoneCount.isOpened + countData.milestoneCount.isClosed);
+ }, [countData]);
+
+ return (
+
+
+
+
+
+
+
+ 레이블({labelCount})
+
+
+
+ 마일스톤({milestoneCount})
+
+
+
+
+ 레이블 추가
+
+
+
+ {isPlusLabelState && }
+
+
+ {labelCount}개의 레이블
+
+ {labelDataIsLoading && (
+
+
+
+ )}
+ {labelData &&
+ labelData.labels.map(({ id, name, description, textColor, bgColor }) => (
+
+ ))}
+ {labelData?.labels.length === 0 && }
+
+
+
+
+ );
+}
+
+const StyledMilestoneBtn = styled(CustomButton)`
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ width: 150px;
+`;
+const StyledLabelBtn = styled(CustomButton)`
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ width: 150px;
+`;
+const NavBtnContainer = styled(FlexRow)`
+ justify-content: end;
+ & .createBtn {
+ margin-left: 10px;
+ }
+ button {
+ font-size: 16px;
+ }
+`;
+
+const NavContainer = styled(FlexRow)`
+ justify-content: space-between;
+ width: 100%;
+ height: 70px;
+ margin-bottom: 10px;
+ button {
+ font-size: 16px;
+ }
+`;
+const StyledBoxBody = styled.div`
+ min-height: 80px;
+ /* background-color: skyblue; */
+`;
+
+const StyledBoxHeader = styled.div`
+ background-color: ${(props) => props.theme.listHeaderColor};
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ height: 60px;
+ color: ${(props) => props.theme.fontColor};
+ border-top-left-radius: 6px;
+ border-top-right-radius: 6px;
+ padding: 0 30px;
+ font-weight: bold;
+`;
+
+const ContentsContainer = styled.div`
+ min-height: 160px;
+ border-radius: 10px;
+ border: 1px solid;
+ border-color: ${(props) => props.theme.borderColor};
+ background-color: ${(props) => props.theme.bgColorBody};
+ color: ${(props) => props.theme.fontColor};
+ margin-top: 10px;
+ /* background-color: azure; */
+`;
+
+const StyledLoader = styled.div`
+ height: 100px;
+ align-content: center;
+`;
diff --git a/FE/src/components/members/AuthLoadingPage.jsx b/FE/src/components/members/AuthLoadingPage.jsx
new file mode 100644
index 000000000..30fa11c1d
--- /dev/null
+++ b/FE/src/components/members/AuthLoadingPage.jsx
@@ -0,0 +1,12 @@
+import React, { useEffect } from 'react';
+
+export default function AuthLoadingPage() {
+ useEffect(() => {
+ const url = new URL(window.location.href);
+ const authorizationCode = url.searchParams.get('code');
+
+ console.log(authorizationCode); //인증 코드
+ }, []);
+
+ return GitHub OAuth 로그인 처리 중...
;
+}
diff --git a/FE/src/components/members/Login.jsx b/FE/src/components/members/Login.jsx
index b474f90a8..e797907c7 100644
--- a/FE/src/components/members/Login.jsx
+++ b/FE/src/components/members/Login.jsx
@@ -5,7 +5,8 @@ import { Link, useNavigate } from 'react-router-dom';
import DarkLogotypeLarge from '../../assets/DarkLogotypeLarge.svg';
import LightLogotypeLarge from '../../assets/LightLogotypeLarge.svg';
import { DarkModeContext } from '../../context/DarkModeContext';
-import { fetchLogin } from '../../api/fetchMembers';
+import { fetchGithubLogin, fetchLogin } from '../../api/fetchMembers';
+import { CustomButton } from '../../assets/CustomButton';
export default function Login() {
const { isDarkMode } = useContext(DarkModeContext);
@@ -59,6 +60,21 @@ export default function Login() {
}
};
+ const handleGithubLogin = async () => {
+ // const loginResult = await fetchGithubLogin();
+ // localStorage.setItem('storeUserData', JSON.stringify(loginResult.data));
+ // console.log(loginResult);
+
+ const CLIENT_ID = 'Ov23liTzJL66RbPZt3fg';
+ // const REDIRECT_URI = `${import.meta.env.VITE_TEAM_SERVER}/api/oauth/github/callback`;
+ const REDIRECT_URI = `${import.meta.env.VITE_TEAM_CLIENT}/members/callback`;
+
+ const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}`;
+
+ // window.location.assign(githubAuthUrl);
+ window.location.href = githubAuthUrl;
+ };
+
useEffect(() => {
setIdInput('');
setPwInput('');
@@ -75,9 +91,10 @@ export default function Login() {
-
+
+
GitHub 계정으로 로그인
-
+
or