upload files

This commit is contained in:
sbinsalman
2025-11-25 11:11:42 -07:00
commit 36f9a5e69b
41 changed files with 1971 additions and 0 deletions

21
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,21 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

25
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

8
frontend/README.md Normal file
View File

@@ -0,0 +1,8 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "sleek-board",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@material-tailwind/react": "^2.1.10",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"vite": "^5.3.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

29
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,29 @@
import "@/styles/App.css";
import UserStories from "@/pages/UserStories";
import ToastContainer from "@/components/common/ToastContainer";
import GlobalLoader from "@/components/layout/loaders/GlobalLoader";
import { ToastProvider } from "@/context/ToastContext";
import { LoaderProvider } from "@/context/LoaderContext";
import { Navigationbar } from "@/components/layout/navbar/Navbar";
function App() {
return (
<ToastProvider>
<LoaderProvider>
<div className="p-4">
<Navigationbar />
<UserStories />
</div>
<ToastContainer />
<GlobalLoader />
</LoaderProvider>
</ToastProvider>
);
}
export default App;

Binary file not shown.

View File

View File

@@ -0,0 +1,280 @@
import { useState, useEffect } from "react";
import { useToast } from "@/context/ToastContext";
import { useLoader } from "@/context/LoaderContext";
import { IoClose } from "react-icons/io5";
import { FaTrashAlt, FaPencilAlt, FaCheck, FaTimes } from "react-icons/fa";
import { getUserStoryByID } from "@/services/apis/UserStories";
import {
addComment,
updateComment,
deleteComment,
} from "@/services/apis/comments";
import {
Textarea,
Button,
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
IconButton
} from "@material-tailwind/react";
const CommentsModal = (props) => {
const {
isCommentOpen,
handleCommentOpen,
userStoryId,
currentUserName = "Developer",
} = props
const toast = useToast();
const { startGlobalLoading, stopGlobalLoading } = useLoader();
// ---------------------------------------
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState("");
const [editingCommentId, setEditingCommentId] = useState(null);
const [editingText, setEditingText] = useState("");
// ---------------------------------------
const handleNewCommentChange = (e) => {
setNewComment(e.target.value);
};
const initUserStory = async (id) => {
if (!id) return;
try {
startGlobalLoading();
const userStory = await getUserStoryByID(id);
setComments(userStory?.comments || []);
}
catch (error) {
console.error("Error fetching user story:", error);
toast.error("Failed to load comments.");
}
finally {
stopGlobalLoading();
}
};
useEffect(() => {
if (isCommentOpen && userStoryId) {
initUserStory(userStoryId);
}
}, [isCommentOpen, userStoryId]);
const submitNewComment = async () => {
if (!newComment.trim()) {
toast.warning("Please write a comment.");
return;
}
if (!userStoryId) {
toast.error("No user story selected.");
return;
}
startGlobalLoading();
try {
const payload = {
userStoryId,
commentText: newComment.trim(),
commentedBy: currentUserName,
};
await addComment(payload);
await initUserStory(userStoryId);
setNewComment("");
toast.success("Comment added successfully!");
}
catch (err) {
console.error("Error adding comment:", err);
toast.error("Failed to add comment.");
}
finally {
stopGlobalLoading();
}
};
const startEditing = (comment) => {
setEditingCommentId(comment._id);
setEditingText(comment.commentText);
};
const cancelEditing = () => {
setEditingCommentId(null);
setEditingText("");
};
const saveEditedComment = async () => {
if (!editingText.trim() || !editingCommentId) {
toast.warning("Please write a comment.");
return;
}
startGlobalLoading();
try {
const payload = { commentText: editingText.trim() };
await updateComment(editingCommentId, payload);
await initUserStory(userStoryId);
cancelEditing();
toast.success("Comment updated successfully!");
}
catch (err) {
console.error("Error updating comment:", err);
toast.error("Failed to update comment.");
}
finally {
stopGlobalLoading();
}
};
const handleDelete = async (id) => {
if (!id) return;
startGlobalLoading();
try {
await deleteComment(id);
await initUserStory(userStoryId);
toast.success("Comment deleted successfully!");
}
catch (err) {
console.error("Error deleting comment:", err);
toast.error("Failed to delete comment.");
}
finally {
stopGlobalLoading();
}
};
return (
<Dialog
size="lg"
open={isCommentOpen}
handler={handleCommentOpen}
dismiss={{ outsidePress: false }}
className="p-2 md:p-8"
>
<DialogHeader className="flex justify-between">
<h4 className="text-h4 md:text-h2">Comments</h4>
<IconButton size="sm" variant="text" onClick={handleCommentOpen}>
<IoClose className="text-h4" />
</IconButton>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-4 overflow-auto h-[15rem] p-2">
{comments.length === 0 && (
<p className="text-sm text-gray-500">
No comments yet. Be the first to add one!
</p>
)}
{comments.map((comment) => (
<div
key={comment._id}
className="flex flex-col text-tPrimary font-primary gap-3 shadow-border rounded p-4"
>
<div className="flex justify-between items-center border-b-2 border-tertiary pb-1">
<h4 className="font-semibold">
By: {comment.commentedBy || "Unknown"}
</h4>
<div className="flex gap-2">
{editingCommentId === comment._id ? (
<>
<IconButton
size="sm"
variant="text"
onClick={saveEditedComment}
>
<FaCheck className="w-4 h-4" />
</IconButton>
<IconButton
size="sm"
variant="text"
onClick={cancelEditing}
>
<FaTimes className="w-4 h-4" />
</IconButton>
</>
) : (
<>
<IconButton
size="sm"
variant="text"
onClick={() => startEditing(comment)}
>
<FaPencilAlt className="w-4 h-4" />
</IconButton>
<IconButton
size="sm"
variant="text"
onClick={() => handleDelete(comment._id)}
>
<FaTrashAlt className="w-4 h-4" />
</IconButton>
</>
)}
</div>
</div>
{editingCommentId === comment._id ? (
<Textarea
variant="outlined"
rows={3}
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
/>
) : (
<p>{comment.commentText}</p>
)}
</div>
))}
</div>
</DialogBody>
<DialogFooter className="w-full flex flex-col items-start gap-4">
<div className="w-full">
<Textarea
label="Add Comment"
variant="outlined"
rows={3}
value={newComment}
onChange={handleNewCommentChange}
className="w-full"
/>
</div>
<Button
onClick={submitNewComment}
className="px-6 py-3"
>
{"Add Comment"}
</Button>
</DialogFooter>
</Dialog>
);
};
export default CommentsModal;

View File

@@ -0,0 +1,29 @@
import { Alert } from '@material-tailwind/react';
import { useToast } from '@/context/ToastContext';
export default function ToastContainer() {
const { toasts, removeToast } = useToast();
if (toasts.length === 0) {
return null;
}
return (
<div className="fixed top-4 right-4 z-[99999] flex flex-col gap-2 max-w-md w-full">
{toasts.map((toast) => (
<Alert
key={toast.id}
color={toast.type}
variant="filled"
className="animate-slide-in-right"
dismissible={{
onClose: () => removeToast(toast.id),
}}
>
{toast.message}
</Alert>
))}
</div>
);
}

View File

@@ -0,0 +1,9 @@
import "@/styles/Loaders.css"
const ClassicLoader = () => {
return (
<div className="classic-loader"></div>
)
}
export default ClassicLoader

View File

@@ -0,0 +1,18 @@
import ClassicLoader from "@/components/common/loaders/ClassicLoader"
import { useLoader } from "@/context/LoaderContext"
const GlobalLoader = () => {
const { isGlobalLoading } = useLoader()
return (
<div>
{isGlobalLoading && (
<div className="fixed top-0 left-0 h-screen w-screen bg-black bg-opacity-80 flex justify-center items-center z-[99999]">
<ClassicLoader />
</div>
)}
</div>
)
}
export default GlobalLoader

View File

@@ -0,0 +1,183 @@
import React from "react";
import {
Navbar,
MobileNav,
Typography,
Button,
Menu,
MenuHandler,
MenuList,
MenuItem,
IconButton,
} from "@material-tailwind/react";
import {
CubeTransparentIcon,
UserCircleIcon,
CodeBracketSquareIcon,
ChevronDownIcon,
Cog6ToothIcon,
InboxArrowDownIcon,
LifebuoyIcon,
Bars2Icon,
Square3Stack3DIcon,
} from "@heroicons/react/24/solid";
// profile menu component
const profileMenuItems = [
{
label: "Product Owner",
icon: UserCircleIcon,
},
{
label: "Scrum Master",
icon: CubeTransparentIcon,
},
{
label: "Team Member",
icon: CodeBracketSquareIcon,
},
{
label: "Help",
icon: LifebuoyIcon,
},
{
label: "settings",
icon: Cog6ToothIcon,
},
];
function ProfileMenu() {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const [userSelectedRole, setUserSelectedRole] = React.useState("Product Owner");
const handleMenuItemClick = (label) => {
setUserSelectedRole(label);
setIsMenuOpen(false);
};
return (
<Menu open={isMenuOpen} handler={setIsMenuOpen} placement="bottom-end">
<MenuHandler>
<Button
variant="text"
color="blue-gray"
className="flex items-center gap-1 rounded-full py-0.5 pr-2 pl-0.5 lg:ml-auto"
>
<Typography
className="hidden lg:inline-flex lg:mr-2 normal-case"
>
{userSelectedRole}
</Typography>
<ChevronDownIcon
strokeWidth={2.5}
className={`h-3 w-3 transition-transform ${
isMenuOpen ? "rotate-180" : ""
}`}
/>
</Button>
</MenuHandler>
<MenuList className="p-1">
{profileMenuItems.map(({ label, icon }, key) => {
return (
<MenuItem
key={label}
onClick={() => handleMenuItemClick(label)}
className={`flex items-center gap-2 rounded`}
>
{React.createElement(icon, {
className: `h-4 w-4`,
strokeWidth: 2,
})}
<Typography
as="span"
variant="small"
className="font-normal"
color={"inherit"}
>
{label}
</Typography>
</MenuItem>
);
})}
</MenuList>
</Menu>
);
}
// nav list component
const navListItems = [
{
label: "Backlog",
icon: Square3Stack3DIcon,
},
{
label: "Create User Story",
icon: InboxArrowDownIcon,
},
];
function NavList() {
return (
<ul className="mt-2 mb-4 flex flex-col gap-2 lg:mb-0 lg:mt-0 lg:flex-row lg:items-center">
{navListItems.map(({ label, icon }, key) => (
<Typography
key={label}
as="a"
href="#"
variant="small"
color="gray"
className="font-medium text-blue-gray-500"
>
<MenuItem className="flex items-center gap-2 lg:rounded-full">
{React.createElement(icon, { className: "h-[18px] w-[18px]" })}{" "}
<span className="text-gray-900"> {label}</span>
</MenuItem>
</Typography>
))}
</ul>
);
}
export function Navigationbar() {
const [isNavOpen, setIsNavOpen] = React.useState(false);
const toggleIsNavOpen = () => setIsNavOpen((cur) => !cur);
React.useEffect(() => {
window.addEventListener(
"resize",
() => window.innerWidth >= 960 && setIsNavOpen(false),
);
}, []);
return (
<Navbar className="mx-auto max-w-screen-xl p-2 lg:rounded-full lg:pl-6">
<div className="relative mx-auto flex items-center justify-between text-blue-gray-900">
<Typography
as="a"
href="#"
className="mr-4 ml-2 cursor-pointer py-1.5 font-medium"
>
SleekBoard
</Typography>
<div className="hidden lg:block">
<NavList />
</div>
<IconButton
size="sm"
color="blue-gray"
variant="text"
onClick={toggleIsNavOpen}
className="ml-auto mr-2 lg:hidden"
>
<Bars2Icon className="h-6 w-6" />
</IconButton>
<ProfileMenu />
</div>
<MobileNav open={isNavOpen} className="overflow-scroll">
<NavList />
</MobileNav>
</Navbar>
);
}

View File

@@ -0,0 +1,187 @@
import { createUserStory, updateUserStory } from '@/services/apis/UserStories';
import { useState, useEffect } from 'react';
import { useToast } from '@/context/ToastContext';
import { useLoader } from "@/context/LoaderContext"
import {
Input,
Textarea,
Select,
Option,
Button,
Dialog,
DialogHeader,
DialogBody,
IconButton
} from '@material-tailwind/react';
import { IoClose } from "react-icons/io5";
const fibonacciSequence = [1, 2, 3, 5, 8, 13, 21, 34, 55];
const statusOptions = ['Todo', 'In-Review', 'Sprint-Ready'];
export default function UserStoryForm(props) {
const {
isFormOpen,
handleFormOpen,
isEditing,
initUserStories,
selectedStory,
viewOnly = false
} = props;
const {
startGlobalLoading,
stopGlobalLoading
} = useLoader();
const toast = useToast();
// --------------------------------------------
const [formData, setFormData] = useState({
title: '',
description: '',
status: statusOptions[0],
businessValue: 1,
storyPoint: fibonacciSequence[0]
});
const [errors, setErrors] = useState({});
// --------------------------------------------
const handleChange = (field) => (event) => {
let value = event?.target?.value ?? '';
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: '' }));
}
};
const handleSelectChange = (field) => (value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: '' }));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) newErrors.title = "Title is required";
if (!formData.status) newErrors.status = "Status is required";
if (!formData.storyPoint) newErrors.storyPoint = "Story Point is required";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (event) => {
event.preventDefault();
if (viewOnly) return;
if (!validateForm()) return;
try {
startGlobalLoading()
isEditing ? await updateUserStory(selectedStory._id, formData) : await createUserStory(formData);
await initUserStories()
resetAndCloseModal()
toast.success(`User story ${isEditing ? "updated" : "created"} successfully!`);
}
catch (err) {
console.error(err);
toast.error(`Failed to ${ isEditing ? "update" : "create"} user story.`);
}
finally {
stopGlobalLoading()
}
};
const handleReset = () => {
setFormData({
title: '',
description: '',
status: statusOptions[0],
businessValue: 1,
storyPoint: fibonacciSequence[0]
});
setErrors({});
};
const resetAndCloseModal = () => {
handleReset();
handleFormOpen();
};
useEffect(() => {
if (isFormOpen && selectedStory) setFormData(selectedStory)
}, [selectedStory, isFormOpen]);
return (
<Dialog size="lg" open={isFormOpen} handler={handleFormOpen} dismiss={{ outsidePress: false }} className="p-2 md:p-8">
<DialogHeader className="flex justify-between">
<h4 className="text-h4 md:text-h2">
{viewOnly ? "View" : isEditing ? "Edit" : "Create"} User Story
</h4>
<IconButton size="sm" variant="text" onClick={resetAndCloseModal}>
<IoClose className="text-h4" />
</IconButton>
</DialogHeader>
<DialogBody>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-3">
<Input label="Title" variant="outlined" value={formData.title} onChange={handleChange("title")} required readOnly={viewOnly} />
</div>
<div className="md:col-span-3">
<Textarea label="Description" variant="outlined" rows={4} value={formData.description} onChange={handleChange("description")} required readOnly={viewOnly} />
</div>
<div>
{viewOnly ? (
<Input label="Status" variant="outlined" value={formData.status} readOnly/>
) : (
<Select label="Status" value={formData.status} onChange={handleSelectChange("status")} required>
{statusOptions.map((option) => (<Option key={option} value={option}>{option}</Option>))}
</Select>
)}
</div>
<div>
<Input label="Business Points" variant="outlined" type="number" min={1} max={100} value={formData.businessValue} onChange={handleChange("businessValue")} required readOnly={viewOnly} />
</div>
<div>
{viewOnly ? (
<Input label="Story Point" variant="outlined" value={String(formData.storyPoint)}readOnly/>
) : (
<Select label="Story Point" value={String(formData.storyPoint)} onChange={handleSelectChange("storyPoint")} required>
{fibonacciSequence.map((point) => ( <Option key={point} value={String(point)}> {point}</Option>))}
</Select>
)}
</div>
{!viewOnly && (
<div className="gap-4 flex flex-col sm:flex-row md:col-span-3">
<Button type="submit">{isEditing ? "Update" : "Create"} User Story</Button>
<Button variant="outlined" onClick={handleReset}>Reset</Button>
</div>
)}
</div>
</form>
</DialogBody>
</Dialog>
);
}

View File

@@ -0,0 +1,22 @@
import { createContext, useContext, useState } from "react";
const LoaderContext = createContext()
export const useLoader = () => {
return useContext(LoaderContext)
}
export const LoaderProvider = ({ children }) => {
const [isGlobalLoading, setIsGlobalLoading] = useState(false)
const startGlobalLoading = () => setIsGlobalLoading(true)
const stopGlobalLoading = () => setIsGlobalLoading(false)
return <LoaderContext.Provider value={{
isGlobalLoading,
startGlobalLoading,
stopGlobalLoading
}}>
{children}
</LoaderContext.Provider>
}

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useState, useCallback } from 'react';
const ToastContext = createContext(null);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]);
const showToast = useCallback((message, type = 'info', duration = 3000) => {
const id = Date.now() + Math.random();
const newToast = {
id,
message,
type,
duration,
};
setToasts((prev) => [...prev, newToast]);
// Auto remove toast after duration
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
return id;
}, []);
const removeToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const success = useCallback((message, duration) => {
return showToast(message, 'green', duration);
}, [showToast]);
const error = useCallback((message, duration) => {
return showToast(message, 'red', duration);
}, [showToast]);
const info = useCallback((message, duration) => {
return showToast(message, 'blue', duration);
}, [showToast]);
const warning = useCallback((message, duration) => {
return showToast(message, 'amber', duration);
}, [showToast]);
const value = {
toasts,
showToast,
removeToast,
success,
error,
info,
warning,
};
return (
<ToastContext.Provider value={value}>
{children}
</ToastContext.Provider>
);
};

13
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import '@/styles/index.css'
import App from '@/App.jsx'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,169 @@
import CommentsModal from "@/components/comments/CommentsModal";
import UserStoryForm from "@/components/user-stories/UserStoryForm";
import { useState, useEffect } from "react";
import { useLoader } from "@/context/LoaderContext"
import { useToast } from '@/context/ToastContext';
import { FaPencilAlt } from "react-icons/fa";
import { FaTrashAlt } from "react-icons/fa";
import { FaEye } from "react-icons/fa";
import { IoMdAdd } from "react-icons/io";
import { BiSolidCommentDetail } from "react-icons/bi";
import { Button } from "@material-tailwind/react";
import { fetchAllUserStories, deleteUserStory } from "@/services/apis/UserStories";
const UserStories = () => {
const toast = useToast();
const {
startGlobalLoading,
stopGlobalLoading
} = useLoader()
// ------------------------------------------------------
const [stories, setStories] = useState([])
const [isFormOpen, setIsFormOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [viewOnly, setViewOnly] = useState(false);
const [selectedStory, setSelectedStory] = useState(null)
// Comments modal state
// Used for comments opened and process the rest in comment modal
const [isCommentOpen, setIsCommentOpen] = useState(false);
const [selectedStoryId, setSelectedStoryId] = useState(null);
// ------------------------------------------------------
const handleFormOpen = () => setIsFormOpen(!isFormOpen)
const handleCommentOpen = () => setIsCommentOpen(!isCommentOpen)
const handleCreateClick = () => {
setIsEditing(false);
setViewOnly(false);
setSelectedStory(null);
handleFormOpen();
};
const handleEditClick = (storyData) => {
setIsEditing(true);
setViewOnly(false);
setSelectedStory(storyData);
handleFormOpen();
};
const handleViewClick = (storyData) => {
setIsEditing(false);
setViewOnly(true);
setSelectedStory(storyData);
handleFormOpen();
};
const handleCommentClick = async (id) => {
handleCommentOpen()
setSelectedStoryId(id);
};
const initUserStories = async () => {
try {
startGlobalLoading()
const userStories = await fetchAllUserStories()
setStories(userStories)
}
catch (error) {
console.error(error)
toast.error("Error Fetching User Stories")
}
finally {
stopGlobalLoading()
}
}
const deleteStory = async (id) => {
try {
startGlobalLoading()
await deleteUserStory(id)
await initUserStories()
toast.success("User Story Deleted Successfully")
}
catch (error) {
console.error(error)
toast.error("Error Deleting User Story")
}
finally {
stopGlobalLoading()
}
}
// ------------------------------------------------------
useEffect(() => {
initUserStories();
}, []);
return (
<main>
<div className="container w-full">
<div className="flex justify-center mt-[10rem]">
<div className="flex flex-col w-full max-w-2xl">
<h4 className="text-h1 font-semibold mb-8 text-center">User Stories</h4>
<Button onClick={handleCreateClick} className="flex items-center self-center gap-3 mb-6 md:self-start">
Create
<IoMdAdd className="text-h6" />
</Button>
{stories.length ? (
<div className="flex flex-col gap-5 h-[30rem] overflow-auto md:h-[20rem]">
{stories.map((storyData) => (
<div key={storyData._id} className="bg-tertiary rounded flex flex-col gap-6 w-full py-4 px-6 sm:grid sm:grid-cols-4">
<div className="text-center sm:text-start sm:col-span-3">
<span>{storyData.title}</span>
</div>
<div className="flex items-center justify-center gap-4 justify-self-end">
<FaEye className="cursor-pointer" onClick={() => handleViewClick(storyData)} />
<FaPencilAlt className="cursor-pointer"
onClick={() => handleEditClick(storyData)} />
<BiSolidCommentDetail className="cursor-pointer text-h6"
onClick={() => handleCommentClick(storyData._id)} />
<FaTrashAlt className="text-red-500 cursor-pointer"
onClick={() => deleteStory(storyData._id)} />
</div>
</div>
))}
</div>
) : (
<h4 className="text-center text-h4 border-2 border-info rounded p-4">No User Stories Available...</h4>
)}
</div>
</div>
</div>
<CommentsModal isCommentOpen={isCommentOpen}
handleCommentOpen={handleCommentOpen}
userStoryId={selectedStoryId}
currentUserName="Developer" />
<UserStoryForm isFormOpen={isFormOpen}
handleFormOpen={handleFormOpen}
isEditing={isEditing}
initUserStories={initUserStories}
viewOnly={viewOnly}
selectedStory={selectedStory} />
</main>
);
};
export default UserStories;

View File

@@ -0,0 +1,13 @@
import Axios from "axios"
const { VITE_BACKEND_URL } = import.meta.env
const Http = Axios.create({
baseURL: VITE_BACKEND_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json"
}
})
export default Http

View File

@@ -0,0 +1,59 @@
// src/services/apis/comments.js
import Http from "@/plugins/Http";
import { HttpStatusCode } from "axios";
export const addComment = async (data) => {
const path = `/api/v1/comments/`;
const options = {
method: "POST",
url: path,
data,
};
try {
const res = await Http(options);
return res?.data ?? res;
} catch (error) {
console.error("Failure in addComments api call");
console.error("Error: " + error);
throw new Error();
}
};
export const updateComment = async (id, data) => {
const path = `/api/v1/comments/${id}`;
const options = {
method: "PUT",
url: path,
data,
};
try {
const res = await Http(options);
return res?.data ?? res;
} catch (error) {
console.error("Failure in updateComment api call");
console.error("Error: " + error);
throw new Error();
}
};
export const deleteComment = async (id) => {
const path = `/api/v1/comments/${id}`;
const options = {
method: "DELETE",
url: path,
};
try {
const res = await Http(options);
return res?.data ?? res;
} catch (error) {
console.error("Failure in Delete Comment api call");
console.error("Error: " + error);
throw new Error();
}
};

View File

@@ -0,0 +1,101 @@
import Http from "@/plugins/Http"
import { HttpStatusCode } from "axios"
export const fetchAllUserStories = async () => {
const path = "/api/v1/user-stories/"
const options = {
method: "GET",
url: path
}
try {
const response = await Http(options)
return response.status == HttpStatusCode.Ok ? response.data : null
}
catch (error) {
console.error("Failure in getAllUserStories api call")
console.error("Error: " + error)
throw new Error()
}
}
export const createUserStory = async (data) => {
const path = `/api/v1/user-stories/`
const options = {
method: "POST",
url: path,
data: data
}
try {
await Http(options)
}
catch (error) {
console.error("Failure in createUserStory api call")
console.error("Error: " + error)
throw new Error()
}
}
export const updateUserStory = async (id, data) => {
const path = `/api/v1/user-stories/${id}`
const options = {
method: "PUT",
url: path,
data: data
}
try {
await Http(options)
}
catch (error) {
console.error("Failure in updateUserStory api call")
console.error("Error: " + error)
throw new Error()
}
}
export const deleteUserStory = async (id) => {
const path = `/api/v1/user-stories/${id}`
const options = {
method: "DELETE",
url: path
}
try {
await Http(options)
}
catch (error) {
console.error("Failure in deleteUserStory api call")
console.error("Error: " + error)
throw new Error()
}
}
export const getUserStoryByID = async (id) => {
const path = `/api/v1/user-stories/${id}`;
const options = {
method: "GET",
url: path,
};
try {
const res = await Http(options);
const story = res?.data;
return story;
} catch (error) {
console.error("Failure in getUserStoryByID api call");
console.error("Error:", error);
throw error;
}
};

View File

@@ -0,0 +1,19 @@
* {
margin: 0;
padding: 0;
text-decoration: none;
list-style-type: none;
box-sizing: border-box;
}
body {
@apply bg-neutral;
@apply text-tPrimary;
@apply font-primary;
@apply text-normal;
font-weight: 400;
}
.shadow-border {
box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 2px 6px 2px;
}

View File

@@ -0,0 +1,21 @@
/* Classic Loader */
.classic-loader {
width: 65px;
height: 65px;
border-radius: 50%;
display: inline-block;
border-top: 3px solid #FFF;
border-right: 3px solid transparent;
box-sizing: border-box;
animation: classicRotation 0.9s linear infinite;
}
@keyframes classicRotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Poppins-Regular";
src: url("../assets/fonts/Poppins-Regular.ttf");
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}

View File

View File

@@ -0,0 +1,67 @@
/** @type {import('tailwindcss').Config} */
import withMT from "@material-tailwind/react/utils/withMT"
export default withMT({
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
"primary": ["Poppins", "sans-serif"],
},
colors: {
"primary": "#333",
"secondary": "#FFF",
"tertiary": "#F0F2F6",
"neutral": "#F7F9FB",
"info": "#999FAA",
"accent": "#F04122",
"tPrimary": "#333",
"tSecondary": "#F7F9FB",
},
fontSize: {
"xxs": "0.625rem",
"xs": "0.75rem",
"sm": "0.875rem",
"normal": "1rem",
"h6": "1.15rem",
"h5": "1.25rem",
"h4": "1.5rem",
"h3": "1.75rem",
"h2": "2rem",
"h1": "2.25rem",
"lg": "2.5rem",
"xl": "2.75rem",
"2xl": "3rem",
"3xl": "3.25rem",
"4xl": "3.5rem",
"5xl": "3.75rem",
"6xl": "4rem",
"7xl": "4.25rem",
"8xl": "4.5rem",
"9xl": "4.75rem",
},
},
screens: {
"xxs": "260px",
"xs": "370px",
"sm": "540px",
"md": "767px",
"lg": "1024px",
"xl": "1200px"
},
container: {
center: true,
},
},
plugins: [],
})

12
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
})