Merge branch 'main' into feature/tasks-api

This commit is contained in:
sosokker 2023-11-13 22:33:51 +07:00
commit b6f7eb3dd3
17 changed files with 1533 additions and 662 deletions

View File

@ -10,6 +10,10 @@
"preview": "vite preview"
},
"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/styled": "^11.11.0",
"@fullcalendar/core": "^6.1.9",
@ -17,35 +21,38 @@
"@fullcalendar/interaction": "^6.1.9",
"@fullcalendar/react": "^6.1.9",
"@fullcalendar/timegrid": "^6.1.9",
"@mui/icons-material": "^5.14.15",
"@mui/material": "^5.14.15",
"@mui/system": "^5.14.15",
"@mui/icons-material": "^5.14.16",
"@mui/material": "^5.14.17",
"@mui/system": "^5.14.17",
"@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",
"dotenv": "^16.3.1",
"framer-motion": "^10.16.4",
"gapi-script": "^1.2.0",
"jwt-decode": "^4.0.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0",
"react-icons": "^4.11.0",
"react-router-dom": "^6.17.0"
"react-router-dom": "^6.18.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.1.1",
"autoprefixer": "^10.4.16",
"daisyui": "^3.9.4",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.32.2",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"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",
"tailwindcss": "^3.3.5",
"vite": "^4.4.5"
"vite": "^4.5.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,42 @@
import './App.css';
import { BrowserRouter, Route, Routes, Link } from 'react-router-dom';
import "./App.css";
import { Route, Routes, useLocation } from "react-router-dom";
import TestAuth from './components/testAuth';
import LoginPage from './components/authentication/LoginPage';
import SignUpPage from './components/authentication/SignUpPage';
import NavBar from './components/nav/Navbar';
import Home from './components/Home';
import ProfileUpdate from './components/ProfileUpdatePage';
import Calendar from './components/calendar/calendar';
import TestAuth from "./components/testAuth";
import LoginPage from "./components/authentication/LoginPage";
import SignUpPage from "./components/authentication/SignUpPage";
import NavBar from "./components/Nav/Navbar";
import Home from "./components/Home";
import ProfileUpdate from "./components/ProfileUpdatePage";
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 location = useLocation();
const prevention = ["/login", "/signup"];
const isLoginPageOrSignUpPage = prevention.some(_ => location.pathname.includes(_));
return (
<BrowserRouter>
<div className="App">
<div className={isLoginPageOrSignUpPage ? "" : "display: flex"}>
{!isLoginPageOrSignUpPage && <IconSideNav />}
<div className={isLoginPageOrSignUpPage ? "" : "flex-1"}>
<NavBar />
<div className={isLoginPageOrSignUpPage ? "" : "flex items-center justify-center"}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage/>}/>
<Route path="/signup" element={<SignUpPage/>}/>
<Route path="/tasks" element={<KanbanBoard />} />
<Route path="/testAuth" element={<TestAuth />} />
<Route path="/update_profile" element={<ProfileUpdate />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/priority" element={<Eisenhower />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignUpPage />} />
</Routes>
</div>
</BrowserRouter>
</div>
</div>
);
}
};
export default App;

View File

@ -1,101 +1,59 @@
import axios from 'axios';
// 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);
}
);
import axios from "axios";
import axiosInstance from "./configs/AxiosConfig";
// Function for user login
const apiUserLogin = (data) => {
const apiUserLogin = data => {
return axiosInstance
.post('token/obtain/', data)
.then((response) => {
.post("token/obtain/", data)
.then(response => {
console.log(response.statusText);
return response;
}).catch(error => {
console.log('apiUserLogin error: ', error);
})
.catch(error => {
console.log("apiUserLogin error: ", error);
return error;
});
};
// Function for user logout
const apiUserLogout = () => {
axiosInstance.defaults.headers['Authorization'] = ""; // Clear authorization header
localStorage.removeItem('access_token'); // Remove access token
localStorage.removeItem('refresh_token'); // Remove refresh token
}
axiosInstance.defaults.headers["Authorization"] = ""; // Clear authorization header
localStorage.removeItem("access_token"); // Remove access token
localStorage.removeItem("refresh_token"); // Remove refresh token
};
// Function for Google login
const googleLogin = async (token) => {
axios.defaults.withCredentials = true
let res = await axios.post(
"http://localhost:8000/api/auth/google/",
{
const googleLogin = async token => {
axios.defaults.withCredentials = true;
let res = await axios.post("http://localhost:8000/api/auth/google/", {
code: token,
}
);
});
// console.log('service google login res: ', res);
return await res;
};
// Function to get 'hello' data
const getGreeting = () => {
return axiosInstance
.get('hello')
.then((response) => {
.get("hello")
.then(response => {
return response;
}).catch(error => {
})
.catch(error => {
return error;
});
}
};
const config = {
headers: {
"Content-Type": "application/json"
}
"Content-Type": "application/json",
},
};
// Function to register
const createUser = async (formData) => {
const createUser = async formData => {
try {
axios.defaults.withCredentials = true
axios.defaults.withCredentials = true;
const resposne = axios.post("http://localhost:8000/api/user/create/", formData);
// const response = await axiosInstance.post('/user/create/', formData);
return response.data;
@ -104,13 +62,11 @@ const createUser = async (formData) => {
}
};
// Export the functions and Axios instance
export default {
axiosInstance,
apiUserLogin,
apiUserLogout,
getGreeting: getGreeting,
googleLogin,
createUser
createUser,
};

View 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;
});
};

View File

@ -1,20 +1,20 @@
import axios from 'axios';
// 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',
}
});
import axiosInstance from "./configs/AxiosConfig";
export const fetchTodoTasks = () => {
return axiosInstance
.get('todo/')
.then((response) => {
.get("todo/")
.then(response => {
return response.data;
})
.catch(error => {
throw error;
});
};
export const fetchTodoTasksID = id => {
return axiosInstance
.get(`todo/${id}/`)
.then(response => {
return response.data;
})
.catch(error => {

View 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;

View 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;

View File

@ -1,25 +1,26 @@
import { useState } from "react";
import {
AiOutlineHome,
AiOutlineSchedule,
AiOutlineUnorderedList,
AiOutlinePieChart,
AiOutlinePlus,
} from "react-icons/ai";
import { AnimatePresence, motion } from "framer-motion";
import { SiFramer, SiTailwindcss, SiReact, SiJavascript, SiCss3 } from "react-icons/si";
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";
import { Link, useNavigate } from "react-router-dom";
const menuItems = [
{ id: 0, icon: <homeLogo />, logo: homeLogo },
{ id: 1, icon: <calendarLogo />, logo: calendarLogo },
{ id: 2, icon: <planLogo />, logo: planLogo },
{ id: 3, icon: <pieLogo />, logo: pieLogo },
{ id: 4, icon: <plusLogo />, logo: plusLogo },
{ id: 0, path: "/", icon: <AiOutlineHome /> },
{ id: 1, path: "/tasks", icon: <AiOutlineUnorderedList /> },
{ id: 2, path: "/calendar", icon: <AiOutlineSchedule /> },
{ id: 3, path: "/analytic", icon: <AiOutlinePieChart /> },
{ id: 4, path: "/priority", icon: <AiOutlinePlus /> },
];
const IconSideNav = () => {
return (
<div className="bg-slate-900 text-slate-100 flex">
<SideNav />
<div className="w-full"></div>
</div>
);
};
@ -28,43 +29,43 @@ const SideNav = () => {
const [selected, setSelected] = useState(0);
return (
<nav className="h-[500px] w-fit bg-slate-950 p-4 flex flex-col items-center gap-2">
{menuItems.map((item) => (
<nav className="bg-slate-950 p-4 flex flex-col items-center gap-2 h-screen">
{menuItems.map(item => (
<NavItem
key={item.id}
icon={item.icon}
selected={selected === item.id}
id={item.id}
setSelected={setSelected}
logo={item.logo}
path={item.path}
/>
))}
</nav>
);
};
const NavItem = ({ icon, selected, id, setSelected, logo }) => {
const NavItem = ({ icon, selected, id, setSelected, logo, path }) => {
const navigate = useNavigate();
return (
<motion.button
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 }}
whileTap={{ scale: 0.95 }}
>
whileTap={{ scale: 0.95 }}>
<AnimatePresence>
{selected && (
<motion.span
className="absolute inset-0 rounded-md bg-indigo-600 z-0"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
></motion.span>
exit={{ scale: 0 }}></motion.span>
)}
</AnimatePresence>
<span className="block relative z-10">
{icon}
<img src={logo} alt="Logo" className="h-8 w-8 mx-auto my-2" />
</span>
<span className="block relative z-10">{icon}</span>
</motion.button>
);
};

View File

@ -1,204 +1,69 @@
import * as React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import IsAuthenticated from '../authentication/IsAuthenticated';
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';
import * as React from "react";
import { useNavigate } from "react-router-dom";
import IsAuthenticated from "../authentication/IsAuthenticated";
import axiosapi from "../../api/AuthenticationApi";
const pages = {
TestAuth: '/testAuth',
};
const settings = {
Profile: '/profile',
Account: '/account',
Dashboard: '/dashboard',
};
function NavBar() {
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 logout = () => {
// Log out the user, clear tokens, and navigate to the "/testAuth" route
axiosapi.apiUserLogout();
Navigate('/');
}
Navigate("/");
};
return (
<AppBar position="static">
<Container maxWidth="xl">
<Toolbar disableGutters>
<AdbIcon sx={{ display: { xs: 'none', md: 'flex' }, mr: 1 }} />
<Typography
variant="h6"
noWrap
component="a"
href="/"
sx={{
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>
<div data-theme="night" className="navbar bg-base-100">
<div className="flex-1">
<a className="btn btn-ghost normal-case text-xl" href="/">
TurTask
</a>
</div>
<div className="flex-none gap-2">
<div className="form-control">
<input type="text" placeholder="Search" className="input input-bordered w-24 md:w-auto" />
</div>
{isAuthenticated ? (
<Box sx={{ flexGrow: 0 }}>
<Tooltip title="Open settings">
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
<Avatar alt="Bullet" src="https://us-tuna-sounds-images.voicemod.net/f322631f-689a-43ac-81ab-17a70f27c443-1692187175560.png" />
</IconButton>
</Tooltip>
<Menu
sx={{ mt: '45px' }}
id="menu-appbar"
anchorEl={anchorElUser}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorElUser)}
onClose={handleCloseUserMenu}
>
{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>
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-circle avatar">
<div className="w-10 rounded-full">
<img src="https://us-tuna-sounds-images.voicemod.net/f322631f-689a-43ac-81ab-17a70f27c443-1692187175560.png" />
</div>
</label>
<ul
tabIndex={0}
className="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-52">
<li>
<a href={settings.Profile} className="justify-between">
Profile
</a>
</li>
<li>
<a href={settings.Account}>Settings</a>
</li>
<li>
<a onClick={logout}>Logout</a>
</li>
</ul>
</div>
) : (
<Stack direction="row" spacing={2}>
<Button variant="contained" href="/login" color="secondary">
<div className="flex gap-2">
<button className="btn btn-outline btn-info" onClick={() => Navigate("/login")}>
Login
</Button>
<Button variant="contained" href="/signup" color="secondary">
</button>
<button className="btn btn-success" onClick={() => Navigate("/signup")}>
Sign Up
</Button>
</Stack>
</button>
</div>
)}
</Toolbar>
</Container>
</AppBar>
</div>
</div>
);
}
export default NavBar;

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useGoogleLogin } from "@react-oauth/google"
import { useGoogleLogin } from "@react-oauth/google";
import refreshAccessToken from './refreshAcesstoken';
import axiosapi from '../../api/AuthenticationApi';
import refreshAccessToken from "./refreshAcesstoken";
import axiosapi from "../../api/AuthenticationApi";
function LoginPage() {
const Navigate = useNavigate();
@ -68,8 +68,7 @@ function LoginPage() {
});
return (
<html data-theme="night">
<div className="min-h-screen flex">
<div data-theme="night" className="min-h-screen flex">
{/* Left Section (Login Box) */}
<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">
@ -136,7 +135,6 @@ function LoginPage() {
</div>
</div>
</div>
</html>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -2,11 +2,14 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { GoogleOAuthProvider} from '@react-oauth/google';
import { BrowserRouter } from 'react-router-dom';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID
ReactDOM.createRoot(document.getElementById("root")).render(
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<BrowserRouter>
<App />
</BrowserRouter>
</GoogleOAuthProvider>
);