mirror of
https://github.com/TurTaskProject/TurTaskWeb.git
synced 2025-12-19 14:04:07 +01:00
Merge branch 'main' into feature/tasks-api
This commit is contained in:
commit
b6f7eb3dd3
@ -10,6 +10,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@asseinfo/react-kanban": "^2.2.0",
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@fullcalendar/core": "^6.1.9",
|
"@fullcalendar/core": "^6.1.9",
|
||||||
@ -17,35 +21,38 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.9",
|
"@fullcalendar/interaction": "^6.1.9",
|
||||||
"@fullcalendar/react": "^6.1.9",
|
"@fullcalendar/react": "^6.1.9",
|
||||||
"@fullcalendar/timegrid": "^6.1.9",
|
"@fullcalendar/timegrid": "^6.1.9",
|
||||||
"@mui/icons-material": "^5.14.15",
|
"@mui/icons-material": "^5.14.16",
|
||||||
"@mui/material": "^5.14.15",
|
"@mui/material": "^5.14.17",
|
||||||
"@mui/system": "^5.14.15",
|
"@mui/system": "^5.14.17",
|
||||||
"@react-oauth/google": "^0.11.1",
|
"@react-oauth/google": "^0.11.1",
|
||||||
"axios": "^1.5.1",
|
"@syncfusion/ej2-base": "^23.1.41",
|
||||||
|
"@syncfusion/ej2-kanban": "^23.1.36",
|
||||||
|
"axios": "^1.6.1",
|
||||||
"bootstrap": "^5.3.2",
|
"bootstrap": "^5.3.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
"gapi-script": "^1.2.0",
|
"gapi-script": "^1.2.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-bootstrap": "^2.9.1",
|
"react-bootstrap": "^2.9.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-router-dom": "^6.17.0"
|
"react-router-dom": "^6.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@vitejs/plugin-react": "^4.0.3",
|
"@vitejs/plugin-react": "^4.1.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"daisyui": "^3.9.4",
|
"daisyui": "^3.9.4",
|
||||||
"eslint": "^8.45.0",
|
"eslint": "^8.53.0",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.3",
|
"eslint-plugin-react-refresh": "^0.4.4",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.3.5",
|
"tailwindcss": "^3.3.5",
|
||||||
"vite": "^4.4.5"
|
"vite": "^4.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,30 +1,42 @@
|
|||||||
import './App.css';
|
import "./App.css";
|
||||||
import { BrowserRouter, Route, Routes, Link } from 'react-router-dom';
|
import { Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import TestAuth from './components/testAuth';
|
import TestAuth from "./components/testAuth";
|
||||||
import LoginPage from './components/authentication/LoginPage';
|
import LoginPage from "./components/authentication/LoginPage";
|
||||||
import SignUpPage from './components/authentication/SignUpPage';
|
import SignUpPage from "./components/authentication/SignUpPage";
|
||||||
import NavBar from './components/nav/Navbar';
|
import NavBar from "./components/Nav/Navbar";
|
||||||
import Home from './components/Home';
|
import Home from "./components/Home";
|
||||||
import ProfileUpdate from './components/ProfileUpdatePage';
|
import ProfileUpdate from "./components/ProfileUpdatePage";
|
||||||
import Calendar from './components/calendar/calendar';
|
import Calendar from "./components/calendar/calendar";
|
||||||
|
import KanbanBoard from "./components/kanbanBoard/kanbanBoard";
|
||||||
|
import IconSideNav from "./components/IconSideNav";
|
||||||
|
import Eisenhower from "./components/eisenhowerMatrix/Eisenhower";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const prevention = ["/login", "/signup"];
|
||||||
|
const isLoginPageOrSignUpPage = prevention.some(_ => location.pathname.includes(_));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<div className={isLoginPageOrSignUpPage ? "" : "display: flex"}>
|
||||||
<div className="App">
|
{!isLoginPageOrSignUpPage && <IconSideNav />}
|
||||||
<NavBar/>
|
<div className={isLoginPageOrSignUpPage ? "" : "flex-1"}>
|
||||||
|
<NavBar />
|
||||||
|
<div className={isLoginPageOrSignUpPage ? "" : "flex items-center justify-center"}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home/>}/>
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/login" element={<LoginPage/>}/>
|
<Route path="/tasks" element={<KanbanBoard />} />
|
||||||
<Route path="/signup" element={<SignUpPage/>}/>
|
<Route path="/testAuth" element={<TestAuth />} />
|
||||||
<Route path="/testAuth" element={<TestAuth/>}/>
|
<Route path="/update_profile" element={<ProfileUpdate />} />
|
||||||
<Route path="/update_profile" element={<ProfileUpdate/>}/>
|
<Route path="/calendar" element={<Calendar />} />
|
||||||
<Route path="/calendar" element={<Calendar/>}/>
|
<Route path="/priority" element={<Eisenhower />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/signup" element={<SignUpPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@ -1,101 +1,59 @@
|
|||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
import axiosInstance from "./configs/AxiosConfig";
|
||||||
// Create an Axios instance with common configurations
|
|
||||||
const axiosInstance = axios.create({
|
|
||||||
baseURL: 'http://127.0.0.1:8000/api/',
|
|
||||||
timeout: 5000,
|
|
||||||
headers: {
|
|
||||||
'Authorization': "Bearer " + localStorage.getItem('access_token'),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'accept': 'application/json',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a response interceptor to handle token refresh on 401 Unauthorized errors
|
|
||||||
axiosInstance.interceptors.response.use(
|
|
||||||
response => response,
|
|
||||||
error => {
|
|
||||||
const originalRequest = error.config;
|
|
||||||
const refresh_token = localStorage.getItem('refresh_token');
|
|
||||||
|
|
||||||
// Check if the error is due to Unauthorized (401) and a refresh token is available
|
|
||||||
if (error.response.status === 401 && error.response.statusText === "Unauthorized" && refresh_token !== "undefined") {
|
|
||||||
return axiosInstance
|
|
||||||
.post('/token/refresh/', { refresh: refresh_token })
|
|
||||||
.then((response) => {
|
|
||||||
// Update access and refresh tokens
|
|
||||||
localStorage.setItem('access_token', response.data.access);
|
|
||||||
localStorage.setItem('refresh_token', response.data.refresh);
|
|
||||||
|
|
||||||
// Update the authorization header with the new access token
|
|
||||||
axiosInstance.defaults.headers['Authorization'] = "Bearer " + response.data.access;
|
|
||||||
originalRequest.headers['Authorization'] = "Bearer " + response.data.access;
|
|
||||||
|
|
||||||
return axiosInstance(originalRequest); // Retry the original request
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.log('Interceptors error: ', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Function for user login
|
// Function for user login
|
||||||
const apiUserLogin = (data) => {
|
const apiUserLogin = data => {
|
||||||
return axiosInstance
|
return axiosInstance
|
||||||
.post('token/obtain/', data)
|
.post("token/obtain/", data)
|
||||||
.then((response) => {
|
.then(response => {
|
||||||
console.log(response.statusText);
|
console.log(response.statusText);
|
||||||
return response;
|
return response;
|
||||||
}).catch(error => {
|
})
|
||||||
console.log('apiUserLogin error: ', error);
|
.catch(error => {
|
||||||
|
console.log("apiUserLogin error: ", error);
|
||||||
return error;
|
return error;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function for user logout
|
// Function for user logout
|
||||||
const apiUserLogout = () => {
|
const apiUserLogout = () => {
|
||||||
axiosInstance.defaults.headers['Authorization'] = ""; // Clear authorization header
|
axiosInstance.defaults.headers["Authorization"] = ""; // Clear authorization header
|
||||||
localStorage.removeItem('access_token'); // Remove access token
|
localStorage.removeItem("access_token"); // Remove access token
|
||||||
localStorage.removeItem('refresh_token'); // Remove refresh token
|
localStorage.removeItem("refresh_token"); // Remove refresh token
|
||||||
}
|
};
|
||||||
|
|
||||||
// Function for Google login
|
// Function for Google login
|
||||||
const googleLogin = async (token) => {
|
const googleLogin = async token => {
|
||||||
axios.defaults.withCredentials = true
|
axios.defaults.withCredentials = true;
|
||||||
let res = await axios.post(
|
let res = await axios.post("http://localhost:8000/api/auth/google/", {
|
||||||
"http://localhost:8000/api/auth/google/",
|
|
||||||
{
|
|
||||||
code: token,
|
code: token,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
// console.log('service google login res: ', res);
|
// console.log('service google login res: ', res);
|
||||||
return await res;
|
return await res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Function to get 'hello' data
|
// Function to get 'hello' data
|
||||||
const getGreeting = () => {
|
const getGreeting = () => {
|
||||||
return axiosInstance
|
return axiosInstance
|
||||||
.get('hello')
|
.get("hello")
|
||||||
.then((response) => {
|
.then(response => {
|
||||||
return response;
|
return response;
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch(error => {
|
||||||
return error;
|
return error;
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to register
|
// Function to register
|
||||||
const createUser = async (formData) => {
|
const createUser = async formData => {
|
||||||
try {
|
try {
|
||||||
axios.defaults.withCredentials = true
|
axios.defaults.withCredentials = true;
|
||||||
const resposne = axios.post("http://localhost:8000/api/user/create/", formData);
|
const resposne = axios.post("http://localhost:8000/api/user/create/", formData);
|
||||||
// const response = await axiosInstance.post('/user/create/', formData);
|
// const response = await axiosInstance.post('/user/create/', formData);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -104,13 +62,11 @@ const createUser = async (formData) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Export the functions and Axios instance
|
// Export the functions and Axios instance
|
||||||
export default {
|
export default {
|
||||||
axiosInstance,
|
|
||||||
apiUserLogin,
|
apiUserLogin,
|
||||||
apiUserLogout,
|
apiUserLogout,
|
||||||
getGreeting: getGreeting,
|
getGreeting: getGreeting,
|
||||||
googleLogin,
|
googleLogin,
|
||||||
createUser
|
createUser,
|
||||||
};
|
};
|
||||||
|
|||||||
12
frontend/src/api/TagApi.jsx
Normal file
12
frontend/src/api/TagApi.jsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import axiosInstance from "./configs/AxiosConfig";
|
||||||
|
|
||||||
|
export const fetchTags = () => {
|
||||||
|
return axiosInstance
|
||||||
|
.get("tags/")
|
||||||
|
.then(response => {
|
||||||
|
return response.data;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,20 +1,20 @@
|
|||||||
import axios from 'axios';
|
import axiosInstance from "./configs/AxiosConfig";
|
||||||
|
|
||||||
// Create an Axios instance with common configurations
|
|
||||||
const axiosInstance = axios.create({
|
|
||||||
baseURL: 'http://127.0.0.1:8000/api/',
|
|
||||||
timeout: 5000,
|
|
||||||
headers: {
|
|
||||||
'Authorization': "Bearer " + localStorage.getItem('access_token'),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'accept': 'application/json',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchTodoTasks = () => {
|
export const fetchTodoTasks = () => {
|
||||||
return axiosInstance
|
return axiosInstance
|
||||||
.get('todo/')
|
.get("todo/")
|
||||||
.then((response) => {
|
.then(response => {
|
||||||
|
return response.data;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchTodoTasksID = id => {
|
||||||
|
return axiosInstance
|
||||||
|
.get(`todo/${id}/`)
|
||||||
|
.then(response => {
|
||||||
return response.data;
|
return response.data;
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
42
frontend/src/api/configs/AxiosConfig.jsx
Normal file
42
frontend/src/api/configs/AxiosConfig.jsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
baseURL: 'http://127.0.0.1:8000/api/',
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {
|
||||||
|
'Authorization': "Bearer " + localStorage.getItem('access_token'),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// handling token refresh on 401 Unauthorized errors
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
error => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
const refresh_token = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
// Check if the error is due to 401 and a refresh token is available
|
||||||
|
if (error.response.status === 401 && error.response.statusText === "Unauthorized" && refresh_token !== "undefined") {
|
||||||
|
return axiosInstance
|
||||||
|
.post('/token/refresh/', { refresh: refresh_token })
|
||||||
|
.then((response) => {
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', response.data.access);
|
||||||
|
|
||||||
|
axiosInstance.defaults.headers['Authorization'] = "Bearer " + response.data.access;
|
||||||
|
originalRequest.headers['Authorization'] = "Bearer " + response.data.access;
|
||||||
|
|
||||||
|
return axiosInstance(originalRequest);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.log('Interceptors error: ', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export default axiosInstance;
|
||||||
30
frontend/src/components/EisenhowerMatrix/Eisenhower.jsx
Normal file
30
frontend/src/components/EisenhowerMatrix/Eisenhower.jsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function EachBlog({ name, colorCode }) {
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-rows-2 gap-4 text-left p-4 rounded-lg bg-white border border-gray-300 shadow-md`}>
|
||||||
|
<div className={`text-xl font-bold`} style={{ color: colorCode }}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className='h-36'>
|
||||||
|
Content goes here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Eisenhower() {
|
||||||
|
return (
|
||||||
|
<div className='bg-slate-100 text-left p-4 m-auto'>
|
||||||
|
<h1 className="text-3xl font-bold mb-4">The Eisenhower Matrix</h1>
|
||||||
|
<div className='grid grid-rows-2 grid-cols-2 gap-2'>
|
||||||
|
<EachBlog name="Urgent & Important" colorCode="#FF5733" />
|
||||||
|
<EachBlog name="Urgent & Not important" colorCode="#FDDD5C" />
|
||||||
|
<EachBlog name="Not urgent & Important" colorCode="#189AB4" />
|
||||||
|
<EachBlog name="Not urgent & Not important" colorCode="#94FA92" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Eisenhower;
|
||||||
@ -1,25 +1,26 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AiOutlineHome,
|
||||||
|
AiOutlineSchedule,
|
||||||
|
AiOutlineUnorderedList,
|
||||||
|
AiOutlinePieChart,
|
||||||
|
AiOutlinePlus,
|
||||||
|
} from "react-icons/ai";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { SiFramer, SiTailwindcss, SiReact, SiJavascript, SiCss3 } from "react-icons/si";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import homeLogo from "../assets/home.png";
|
|
||||||
import calendarLogo from "../assets/calendar.png";
|
|
||||||
import planLogo from "../assets/planning.png";
|
|
||||||
import pieLogo from "../assets/pie-chart.png";
|
|
||||||
import plusLogo from "../assets/plus.png";
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: 0, icon: <homeLogo />, logo: homeLogo },
|
{ id: 0, path: "/", icon: <AiOutlineHome /> },
|
||||||
{ id: 1, icon: <calendarLogo />, logo: calendarLogo },
|
{ id: 1, path: "/tasks", icon: <AiOutlineUnorderedList /> },
|
||||||
{ id: 2, icon: <planLogo />, logo: planLogo },
|
{ id: 2, path: "/calendar", icon: <AiOutlineSchedule /> },
|
||||||
{ id: 3, icon: <pieLogo />, logo: pieLogo },
|
{ id: 3, path: "/analytic", icon: <AiOutlinePieChart /> },
|
||||||
{ id: 4, icon: <plusLogo />, logo: plusLogo },
|
{ id: 4, path: "/priority", icon: <AiOutlinePlus /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const IconSideNav = () => {
|
const IconSideNav = () => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-900 text-slate-100 flex">
|
<div className="bg-slate-900 text-slate-100 flex">
|
||||||
<SideNav />
|
<SideNav />
|
||||||
<div className="w-full"></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -28,43 +29,43 @@ const SideNav = () => {
|
|||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="h-[500px] w-fit bg-slate-950 p-4 flex flex-col items-center gap-2">
|
<nav className="bg-slate-950 p-4 flex flex-col items-center gap-2 h-screen">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map(item => (
|
||||||
<NavItem
|
<NavItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
selected={selected === item.id}
|
selected={selected === item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
logo={item.logo}
|
path={item.path}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavItem = ({ icon, selected, id, setSelected, logo }) => {
|
const NavItem = ({ icon, selected, id, setSelected, logo, path }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
className="p-3 text-xl bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative"
|
className="p-3 text-xl bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative"
|
||||||
onClick={() => setSelected(id)}
|
onClick={() => {
|
||||||
|
setSelected(id);
|
||||||
|
navigate(path);
|
||||||
|
}}
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}>
|
||||||
>
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{selected && (
|
{selected && (
|
||||||
<motion.span
|
<motion.span
|
||||||
className="absolute inset-0 rounded-md bg-indigo-600 z-0"
|
className="absolute inset-0 rounded-md bg-indigo-600 z-0"
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
exit={{ scale: 0 }}
|
exit={{ scale: 0 }}></motion.span>
|
||||||
></motion.span>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<span className="block relative z-10">
|
<span className="block relative z-10">{icon}</span>
|
||||||
{icon}
|
|
||||||
<img src={logo} alt="Logo" className="h-8 w-8 mx-auto my-2" />
|
|
||||||
</span>
|
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,204 +1,69 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import IsAuthenticated from '../authentication/IsAuthenticated';
|
import IsAuthenticated from "../authentication/IsAuthenticated";
|
||||||
import axiosapi from '../../api/AuthenticationApi';
|
import axiosapi from "../../api/AuthenticationApi";
|
||||||
import AppBar from '@mui/material/AppBar';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
|
||||||
import Typography from '@mui/material/Typography';
|
|
||||||
import Menu from '@mui/material/Menu';
|
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
|
||||||
import Container from '@mui/material/Container';
|
|
||||||
import Avatar from '@mui/material/Avatar';
|
|
||||||
import Button from '@mui/material/Button';
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
|
||||||
import AdbIcon from '@mui/icons-material/Adb';
|
|
||||||
import Stack from '@mui/material/Stack';
|
|
||||||
|
|
||||||
const pages = {
|
|
||||||
TestAuth: '/testAuth',
|
|
||||||
|
|
||||||
};
|
|
||||||
const settings = {
|
const settings = {
|
||||||
Profile: '/profile',
|
Profile: '/profile',
|
||||||
Account: '/account',
|
Account: '/account',
|
||||||
Dashboard: '/dashboard',
|
};
|
||||||
};
|
|
||||||
|
|
||||||
function NavBar() {
|
function NavBar() {
|
||||||
const Navigate = useNavigate();
|
const Navigate = useNavigate();
|
||||||
const [anchorElNav, setAnchorElNav] = React.useState(null);
|
|
||||||
const [anchorElUser, setAnchorElUser] = React.useState(null);
|
|
||||||
|
|
||||||
const handleOpenNavMenu = (event) => {
|
|
||||||
setAnchorElNav(event.currentTarget);
|
|
||||||
};
|
|
||||||
const handleOpenUserMenu = (event) => {
|
|
||||||
setAnchorElUser(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseNavMenu = () => {
|
|
||||||
setAnchorElNav(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseUserMenu = () => {
|
|
||||||
setAnchorElUser(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAuthenticated = IsAuthenticated();
|
const isAuthenticated = IsAuthenticated();
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
// Log out the user, clear tokens, and navigate to the "/testAuth" route
|
|
||||||
axiosapi.apiUserLogout();
|
axiosapi.apiUserLogout();
|
||||||
Navigate('/');
|
Navigate("/");
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="static">
|
<div data-theme="night" className="navbar bg-base-100">
|
||||||
<Container maxWidth="xl">
|
<div className="flex-1">
|
||||||
<Toolbar disableGutters>
|
<a className="btn btn-ghost normal-case text-xl" href="/">
|
||||||
<AdbIcon sx={{ display: { xs: 'none', md: 'flex' }, mr: 1 }} />
|
TurTask
|
||||||
<Typography
|
</a>
|
||||||
variant="h6"
|
</div>
|
||||||
noWrap
|
<div className="flex-none gap-2">
|
||||||
component="a"
|
<div className="form-control">
|
||||||
href="/"
|
<input type="text" placeholder="Search" className="input input-bordered w-24 md:w-auto" />
|
||||||
sx={{
|
</div>
|
||||||
mr: 2,
|
|
||||||
display: { xs: 'none', md: 'flex' },
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '.3rem',
|
|
||||||
color: 'inherit',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
LOGO
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1, display: { xs: 'flex', md: 'none' } }}>
|
|
||||||
<IconButton
|
|
||||||
size="large"
|
|
||||||
aria-label="account of current user"
|
|
||||||
aria-controls="menu-appbar"
|
|
||||||
aria-haspopup="true"
|
|
||||||
onClick={handleOpenNavMenu}
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
<MenuIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Menu
|
|
||||||
id="menu-appbar"
|
|
||||||
anchorEl={anchorElNav}
|
|
||||||
anchorOrigin={{
|
|
||||||
vertical: 'bottom',
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
keepMounted
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: 'top',
|
|
||||||
horizontal: 'left',
|
|
||||||
}}
|
|
||||||
open={Boolean(anchorElNav)}
|
|
||||||
onClose={handleCloseNavMenu}
|
|
||||||
sx={{
|
|
||||||
display: { xs: 'block', md: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.entries(pages).map(([page, path]) => (
|
|
||||||
<MenuItem key={page} onClick={handleCloseNavMenu}>
|
|
||||||
<Link to={path} className="nav-link">
|
|
||||||
<Typography textAlign="center">{page}</Typography>
|
|
||||||
</Link>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</Box>
|
|
||||||
<AdbIcon sx={{ display: { xs: 'flex', md: 'none' }, mr: 1 }} />
|
|
||||||
<Typography
|
|
||||||
variant="h5"
|
|
||||||
noWrap
|
|
||||||
component="a"
|
|
||||||
href="#app-bar-with-responsive-menu"
|
|
||||||
sx={{
|
|
||||||
mr: 2,
|
|
||||||
display: { xs: 'flex', md: 'none' },
|
|
||||||
flexGrow: 1,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '.3rem',
|
|
||||||
color: 'inherit',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
LOGO
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
|
|
||||||
{Object.entries(pages).map(([page, path]) => (
|
|
||||||
<Button
|
|
||||||
key={page}
|
|
||||||
component={Link} // Use the Link component
|
|
||||||
to={path} // Specify the target path
|
|
||||||
onClick={handleCloseNavMenu}
|
|
||||||
sx={{ my: 2, color: 'white', display: 'block' }}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Box sx={{ flexGrow: 0 }}>
|
<div className="dropdown dropdown-end">
|
||||||
<Tooltip title="Open settings">
|
<label tabIndex={0} className="btn btn-ghost btn-circle avatar">
|
||||||
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
|
<div className="w-10 rounded-full">
|
||||||
<Avatar alt="Bullet" src="https://us-tuna-sounds-images.voicemod.net/f322631f-689a-43ac-81ab-17a70f27c443-1692187175560.png" />
|
<img src="https://us-tuna-sounds-images.voicemod.net/f322631f-689a-43ac-81ab-17a70f27c443-1692187175560.png" />
|
||||||
</IconButton>
|
</div>
|
||||||
</Tooltip>
|
</label>
|
||||||
<Menu
|
<ul
|
||||||
sx={{ mt: '45px' }}
|
tabIndex={0}
|
||||||
id="menu-appbar"
|
className="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-52">
|
||||||
anchorEl={anchorElUser}
|
<li>
|
||||||
anchorOrigin={{
|
<a href={settings.Profile} className="justify-between">
|
||||||
vertical: 'top',
|
Profile
|
||||||
horizontal: 'right',
|
</a>
|
||||||
}}
|
</li>
|
||||||
keepMounted
|
<li>
|
||||||
transformOrigin={{
|
<a href={settings.Account}>Settings</a>
|
||||||
vertical: 'top',
|
</li>
|
||||||
horizontal: 'right',
|
<li>
|
||||||
}}
|
<a onClick={logout}>Logout</a>
|
||||||
open={Boolean(anchorElUser)}
|
</li>
|
||||||
onClose={handleCloseUserMenu}
|
</ul>
|
||||||
>
|
</div>
|
||||||
{Object.entries(settings).map(([setting, path]) => (
|
|
||||||
<MenuItem key={setting} onClick={handleCloseUserMenu}>
|
|
||||||
<Link to={path} className="nav-link">
|
|
||||||
<Typography textAlign="center">{setting}</Typography>
|
|
||||||
</Link>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
<MenuItem>
|
|
||||||
<Link to={'/'} onClick={logout} className="nav-link">
|
|
||||||
<Typography textAlign="center">Logout</Typography>
|
|
||||||
</Link>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack direction="row" spacing={2}>
|
<div className="flex gap-2">
|
||||||
<Button variant="contained" href="/login" color="secondary">
|
<button className="btn btn-outline btn-info" onClick={() => Navigate("/login")}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</button>
|
||||||
<Button variant="contained" href="/signup" color="secondary">
|
<button className="btn btn-success" onClick={() => Navigate("/signup")}>
|
||||||
Sign Up
|
Sign Up
|
||||||
</Button>
|
</button>
|
||||||
</Stack>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Toolbar>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
</AppBar>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default NavBar;
|
export default NavBar;
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useGoogleLogin } from "@react-oauth/google"
|
import { useGoogleLogin } from "@react-oauth/google";
|
||||||
|
|
||||||
import refreshAccessToken from './refreshAcesstoken';
|
import refreshAccessToken from "./refreshAcesstoken";
|
||||||
import axiosapi from '../../api/AuthenticationApi';
|
import axiosapi from "../../api/AuthenticationApi";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const Navigate = useNavigate();
|
const Navigate = useNavigate();
|
||||||
@ -68,8 +68,7 @@ function LoginPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html data-theme="night">
|
<div data-theme="night" className="min-h-screen flex">
|
||||||
<div className="min-h-screen flex">
|
|
||||||
{/* Left Section (Login Box) */}
|
{/* Left Section (Login Box) */}
|
||||||
<div className="w-1/2 flex items-center justify-center">
|
<div className="w-1/2 flex items-center justify-center">
|
||||||
<div className="w-96 bg-neutral rounded-lg p-8 shadow-md space-y-4">
|
<div className="w-96 bg-neutral rounded-lg p-8 shadow-md space-y-4">
|
||||||
@ -136,7 +135,6 @@ function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</html>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
frontend/src/components/icons/plusIcon.jsx
Normal file
22
frontend/src/components/icons/plusIcon.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function PlusIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlusIcon;
|
||||||
23
frontend/src/components/icons/trashIcon.jsx
Normal file
23
frontend/src/components/icons/trashIcon.jsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function TrashIcon() {
|
||||||
|
return (
|
||||||
|
React.createElement(
|
||||||
|
"svg",
|
||||||
|
{
|
||||||
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
|
fill: "none",
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
className: "w-6 h-6"
|
||||||
|
},
|
||||||
|
React.createElement("path", {
|
||||||
|
strokeLinecap: "round",
|
||||||
|
strokeLinejoin: "round",
|
||||||
|
d: "M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrashIcon;
|
||||||
178
frontend/src/components/kanbanBoard/columnContainer.jsx
Normal file
178
frontend/src/components/kanbanBoard/columnContainer.jsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||||
|
import TrashIcon from "../icons/trashIcon";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import PlusIcon from "../icons/plusIcon";
|
||||||
|
import TaskCard from "./taskCard";
|
||||||
|
|
||||||
|
function ColumnContainer({
|
||||||
|
column,
|
||||||
|
deleteColumn,
|
||||||
|
updateColumn,
|
||||||
|
createTask,
|
||||||
|
tasks,
|
||||||
|
deleteTask,
|
||||||
|
updateTask,
|
||||||
|
}) {
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
const tasksIds = useMemo(() => {
|
||||||
|
return tasks.map((task) => task.id);
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: column.id,
|
||||||
|
data: {
|
||||||
|
type: "Column",
|
||||||
|
column,
|
||||||
|
},
|
||||||
|
disabled: editMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transition,
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="
|
||||||
|
bg-columnBackgroundColor
|
||||||
|
opacity-40
|
||||||
|
border-2
|
||||||
|
border-pink-500
|
||||||
|
w-[350px]
|
||||||
|
h-[500px]
|
||||||
|
max-h-[500px]
|
||||||
|
rounded-md
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="
|
||||||
|
bg-columnBackgroundColor
|
||||||
|
w-[350px]
|
||||||
|
h-[500px]
|
||||||
|
max-h-[500px]
|
||||||
|
rounded-md
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Column title */}
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={() => {
|
||||||
|
setEditMode(true);
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
bg-mainBackgroundColor
|
||||||
|
text-md
|
||||||
|
h-[60px]
|
||||||
|
cursor-grab
|
||||||
|
rounded-md
|
||||||
|
rounded-b-none
|
||||||
|
p-3
|
||||||
|
font-bold
|
||||||
|
border-columnBackgroundColor
|
||||||
|
border-4
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-between
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
flex
|
||||||
|
justify-center
|
||||||
|
items-center
|
||||||
|
bg-columnBackgroundColor
|
||||||
|
px-2
|
||||||
|
py-1
|
||||||
|
text-sm
|
||||||
|
rounded-full
|
||||||
|
"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
{!editMode && column.title}
|
||||||
|
{editMode && (
|
||||||
|
<input
|
||||||
|
className="bg-black focus:border-rose-500 border rounded outline-none px-2"
|
||||||
|
value={column.title}
|
||||||
|
onChange={(e) => updateColumn(column.id, e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onBlur={() => {
|
||||||
|
setEditMode(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
setEditMode(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
deleteColumn(column.id);
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
stroke-gray-500
|
||||||
|
hover:stroke-white
|
||||||
|
hover:bg-columnBackgroundColor
|
||||||
|
rounded
|
||||||
|
px-1
|
||||||
|
py-2
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column task container */}
|
||||||
|
<div className="flex flex-grow flex-col gap-4 p-2 overflow-x-hidden overflow-y-auto">
|
||||||
|
<SortableContext items={tasksIds}>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
{/* Column footer */}
|
||||||
|
<button
|
||||||
|
className="flex gap-2 items-center border-columnBackgroundColor border-2 rounded-md p-4 border-x-columnBackgroundColor hover:bg-mainBackgroundColor hover:text-rose-500 active:bg-black"
|
||||||
|
onClick={() => {
|
||||||
|
createTask(column.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColumnContainer;
|
||||||
336
frontend/src/components/kanbanBoard/kanbanBoard.jsx
Normal file
336
frontend/src/components/kanbanBoard/kanbanBoard.jsx
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import PlusIcon from "../icons/plusIcon"
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import ColumnContainer from "./columnContainer";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import TaskCard from "./taskCard";
|
||||||
|
|
||||||
|
const defaultCols = [
|
||||||
|
{
|
||||||
|
id: "todo",
|
||||||
|
title: "Todo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doing",
|
||||||
|
title: "Work in progress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "done",
|
||||||
|
title: "Done",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultTasks = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
columnId: "todo",
|
||||||
|
content: "List admin APIs for dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
columnId: "todo",
|
||||||
|
content:
|
||||||
|
"Develop user registration functionality with OTP delivered on SMS after email confirmation and phone number confirmation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
columnId: "doing",
|
||||||
|
content: "Conduct security testing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
columnId: "doing",
|
||||||
|
content: "Analyze competitors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
columnId: "done",
|
||||||
|
content: "Create UI kit documentation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
columnId: "done",
|
||||||
|
content: "Dev meeting",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "7",
|
||||||
|
columnId: "done",
|
||||||
|
content: "Deliver dashboard prototype",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "8",
|
||||||
|
columnId: "todo",
|
||||||
|
content: "Optimize application performance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9",
|
||||||
|
columnId: "todo",
|
||||||
|
content: "Implement data validation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "10",
|
||||||
|
columnId: "todo",
|
||||||
|
content: "Design database schema",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "11",
|
||||||
|
columnId: "todo",
|
||||||
|
content: "Integrate SSL web certificates into workflow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "12",
|
||||||
|
columnId: "doing",
|
||||||
|
content: "Implement error logging and monitoring",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "13",
|
||||||
|
columnId: "doing",
|
||||||
|
content: "Design and implement responsive UI",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function KanbanBoard() {
|
||||||
|
const [columns, setColumns] = useState(defaultCols);
|
||||||
|
const columnsId = useMemo(() => columns.map((col) => col.id), [columns]);
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState(defaultTasks);
|
||||||
|
|
||||||
|
const [activeColumn, setActiveColumn] = useState(null);
|
||||||
|
|
||||||
|
const [activeTask, setActiveTask] = useState(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
m-auto
|
||||||
|
flex
|
||||||
|
w-full
|
||||||
|
items-center
|
||||||
|
overflow-x-auto
|
||||||
|
overflow-y-hidden
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
>
|
||||||
|
<div className="m-auto flex gap-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SortableContext items={columnsId}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<ColumnContainer
|
||||||
|
key={col.id}
|
||||||
|
column={col}
|
||||||
|
deleteColumn={deleteColumn}
|
||||||
|
updateColumn={updateColumn}
|
||||||
|
createTask={createTask}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
tasks={tasks.filter((task) => task.columnId === col.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
createNewColumn();
|
||||||
|
}}
|
||||||
|
className="
|
||||||
|
h-[60px]
|
||||||
|
w-[350px]
|
||||||
|
min-w-[350px]
|
||||||
|
cursor-pointer
|
||||||
|
rounded-lg
|
||||||
|
bg-mainBackgroundColor
|
||||||
|
border-2
|
||||||
|
border-columnBackgroundColor
|
||||||
|
p-4
|
||||||
|
ring-rose-500
|
||||||
|
hover:ring-2
|
||||||
|
flex
|
||||||
|
gap-2
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add Column
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createPortal(
|
||||||
|
<DragOverlay>
|
||||||
|
{activeColumn && (
|
||||||
|
<ColumnContainer
|
||||||
|
column={activeColumn}
|
||||||
|
deleteColumn={deleteColumn}
|
||||||
|
updateColumn={updateColumn}
|
||||||
|
createTask={createTask}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
tasks={tasks.filter(
|
||||||
|
(task) => task.columnId === activeColumn.id
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTask && (
|
||||||
|
<TaskCard
|
||||||
|
task={activeTask}
|
||||||
|
deleteTask={deleteTask}
|
||||||
|
updateTask={updateTask}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DragOverlay>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function createTask(columnId) {
|
||||||
|
const newTask = {
|
||||||
|
id: generateId(),
|
||||||
|
columnId,
|
||||||
|
content: `Task ${tasks.length + 1}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTasks([...tasks, newTask]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTask(id) {
|
||||||
|
const newTasks = tasks.filter((task) => task.id !== id);
|
||||||
|
setTasks(newTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTask(id, content) {
|
||||||
|
const newTasks = tasks.map((task) => {
|
||||||
|
if (task.id !== id) return task;
|
||||||
|
return { ...task, content };
|
||||||
|
});
|
||||||
|
|
||||||
|
setTasks(newTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewColumn() {
|
||||||
|
const columnToAdd = {
|
||||||
|
id: generateId(),
|
||||||
|
title: `Column ${columns.length + 1}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
setColumns([...columns, columnToAdd]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteColumn(id) {
|
||||||
|
const filteredColumns = columns.filter((col) => col.id !== id);
|
||||||
|
setColumns(filteredColumns);
|
||||||
|
|
||||||
|
const newTasks = tasks.filter((t) => t.columnId !== id);
|
||||||
|
setTasks(newTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColumn(id, title) {
|
||||||
|
const newColumns = columns.map((col) => {
|
||||||
|
if (col.id !== id) return col;
|
||||||
|
return { ...col, title };
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumns(newColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(event) {
|
||||||
|
if (event.active.data.current?.type === "Column") {
|
||||||
|
setActiveColumn(event.active.data.current.column);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.active.data.current?.type === "Task") {
|
||||||
|
setActiveTask(event.active.data.current.task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd(event) {
|
||||||
|
setActiveColumn(null);
|
||||||
|
setActiveTask(null);
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
if (activeId === overId) return;
|
||||||
|
|
||||||
|
const isActiveAColumn = active.data.current?.type === "Column";
|
||||||
|
if (!isActiveAColumn) return;
|
||||||
|
|
||||||
|
setColumns((columns) => {
|
||||||
|
const activeColumnIndex = columns.findIndex((col) => col.id === activeId);
|
||||||
|
|
||||||
|
const overColumnIndex = columns.findIndex((col) => col.id === overId);
|
||||||
|
|
||||||
|
return arrayMove(columns, activeColumnIndex, overColumnIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(event) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
if (activeId === overId) return;
|
||||||
|
|
||||||
|
const isActiveATask = active.data.current?.type === "Task";
|
||||||
|
const isOverATask = over.data.current?.type === "Task";
|
||||||
|
|
||||||
|
if (!isActiveATask) return;
|
||||||
|
|
||||||
|
if (isActiveATask && isOverATask) {
|
||||||
|
setTasks((tasks) => {
|
||||||
|
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||||
|
const overIndex = tasks.findIndex((t) => t.id === overId);
|
||||||
|
|
||||||
|
if (tasks[activeIndex].columnId !== tasks[overIndex].columnId) {
|
||||||
|
tasks[activeIndex].columnId = tasks[overIndex].columnId;
|
||||||
|
return arrayMove(tasks, activeIndex, overIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return arrayMove(tasks, activeIndex, overIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOverAColumn = over.data.current?.type === "Column";
|
||||||
|
|
||||||
|
if (isActiveATask && isOverAColumn) {
|
||||||
|
setTasks((tasks) => {
|
||||||
|
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||||
|
|
||||||
|
tasks[activeIndex].columnId = overId;
|
||||||
|
return arrayMove(tasks, activeIndex, activeIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return Math.floor(Math.random() * 10001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KanbanBoard;
|
||||||
111
frontend/src/components/kanbanBoard/taskCard.jsx
Normal file
111
frontend/src/components/kanbanBoard/taskCard.jsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import TrashIcon from "../icons/trashIcon";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
function TaskCard({ task, deleteTask, updateTask }) {
|
||||||
|
const [mouseIsOver, setMouseIsOver] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(true);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
type: "Task",
|
||||||
|
task,
|
||||||
|
},
|
||||||
|
disabled: editMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transition,
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEditMode = () => {
|
||||||
|
setEditMode((prev) => !prev);
|
||||||
|
setMouseIsOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="
|
||||||
|
opacity-30
|
||||||
|
bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl border-2 border-rose-500 cursor-grab relative
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="
|
||||||
|
h-[90%]
|
||||||
|
w-full resize-none border-none rounded bg-transparent text-white focus:outline-none
|
||||||
|
"
|
||||||
|
value={task.content}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Task content here"
|
||||||
|
onBlur={toggleEditMode}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && e.shiftKey) {
|
||||||
|
toggleEditMode();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => updateTask(task.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={toggleEditMode}
|
||||||
|
className="bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative task"
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setMouseIsOver(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setMouseIsOver(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="my-auto h-[90%] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap">
|
||||||
|
{task.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{mouseIsOver && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
deleteTask(task.id);
|
||||||
|
}}
|
||||||
|
className="stroke-white absolute right-4 top-1/2 -translate-y-1/2 bg-columnBackgroundColor p-2 rounded opacity-60 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskCard;
|
||||||
@ -2,11 +2,14 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { GoogleOAuthProvider} from '@react-oauth/google';
|
import { GoogleOAuthProvider} from '@react-oauth/google';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID
|
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</GoogleOAuthProvider>
|
</GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
Loading…
Reference in New Issue
Block a user