upload files
This commit is contained in:
90
README.md
Normal file
90
README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# SleekBoard
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This project consists of a **Node.js backend** and a **React frontend**, both located in their respective subdirectories.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
Make sure you have the following installed:
|
||||||
|
- [Node.js](https://nodejs.org/) (22.21.0 or higher)
|
||||||
|
- [npm](https://www.npmjs.com/) (10.9.4 or higher)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
project-root/
|
||||||
|
│-- backend/ # Node.js backend
|
||||||
|
│-- frontend/ # React frontend
|
||||||
|
│-- README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Setup
|
||||||
|
|
||||||
|
### Navigate to the backend directory
|
||||||
|
```sh
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure Environment Variables
|
||||||
|
Create a `.env` file in the `backend/` directory and add necessary environment variables.
|
||||||
|
```
|
||||||
|
PORT=5050
|
||||||
|
MONGO_URI=mongodb+srv://sleekBoard_team:cpZaj3w0lgma80BY@cluster0.egyquin.mongodb.net/?appName=Cluster0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the backend server
|
||||||
|
```sh
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running in Development Mode
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the backend runs on `http://localhost:5050/` (or the port specified in `.env`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Setup
|
||||||
|
|
||||||
|
### Navigate to the frontend directory
|
||||||
|
```sh
|
||||||
|
cd ../frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install dependencies
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure Environment Variables
|
||||||
|
Create a `.env` file in the `frontend/` directory and add necessary environment variables.
|
||||||
|
```
|
||||||
|
VITE_BACKEND_URL = http://localhost:5050
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the frontend server
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the frontend server in Development mode
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the frontend runs on `http://localhost:5173/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
- Ensure the backend is running before accessing the frontend.
|
||||||
|
- Update `.env` files with appropriate values for production environments.
|
||||||
|
---
|
||||||
BIN
backend/.DS_Store
vendored
Normal file
BIN
backend/.DS_Store
vendored
Normal file
Binary file not shown.
139
backend/.gitignore
vendored
Normal file
139
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Sveltekit cache directory
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Firebase cache directory
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Vite logs files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
78
backend/controllers/comments.controller.js
Normal file
78
backend/controllers/comments.controller.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import Comment from "../models/comments.model.js";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
// POST /api/comments
|
||||||
|
export const createComment = async (req, res) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const { userStoryId, commentText, commentedBy } = req.body;
|
||||||
|
|
||||||
|
if (!userStoryId || !commentText)
|
||||||
|
{
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).json({ message: "userStoryId and text are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = new Comment({userStoryId,commentText,commentedBy});
|
||||||
|
|
||||||
|
const saved = await comment.save();
|
||||||
|
return res.status(StatusCodes.CREATED).json(saved);
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
console.error("Error creating comment", err);
|
||||||
|
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: "Error creating comment" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// PUT /api/comments/:id
|
||||||
|
export const updateComment = async (req, res) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const { id } = req.params;
|
||||||
|
const { commentText } = req.body;
|
||||||
|
|
||||||
|
if (!commentText)
|
||||||
|
{
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).json({ message: "text is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await Comment.findByIdAndUpdate(id,{ commentText },{ new: true });
|
||||||
|
|
||||||
|
if (!updated)
|
||||||
|
{
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).json({ message: "Comment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(StatusCodes.OK).json(updated);
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
console.error("Error updating comment", err);
|
||||||
|
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: "Error updating comment" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/comments/:id
|
||||||
|
export const deleteComment = async (req, res) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const deleted = await Comment.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
if (!deleted)
|
||||||
|
{
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).json({ message: "Comment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(StatusCodes.OK).json({ message: "Comment deleted successfully" });
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
console.error("Error deleting comment", err);
|
||||||
|
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: "Error deleting comment" });
|
||||||
|
}
|
||||||
|
};
|
||||||
64
backend/controllers/userStories.controller.js
Normal file
64
backend/controllers/userStories.controller.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import UserStory from "../models/userStories.model.js";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
import Comment from "../models/comments.model.js";
|
||||||
|
|
||||||
|
|
||||||
|
export async function createUserStories(req, res) {
|
||||||
|
try {
|
||||||
|
const userStory = new UserStory(req.body);
|
||||||
|
const savedStory = await userStory.save();
|
||||||
|
|
||||||
|
res.status(StatusCodes.CREATED).json(savedStory);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(StatusCodes.NOT_FOUND).json({ error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserStory(req, res) {
|
||||||
|
try {
|
||||||
|
const userStory = await UserStory.findById(req.params.id).populate({
|
||||||
|
path: "comments",
|
||||||
|
options: { sort: { createdAt: -1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userStory) {
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).json({ message: "User story not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(StatusCodes.OK).json(userStory);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserStory(req, res) {
|
||||||
|
try {
|
||||||
|
const updatedStory = await UserStory.findByIdAndUpdate(req.params.id, req.body, { new: true });
|
||||||
|
if (!updatedStory) {
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).json({ message: "User story not found" });
|
||||||
|
}
|
||||||
|
res.status(StatusCodes.OK).json(updatedStory);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(StatusCodes.BAD_REQUEST).json({ message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserStory(req, res) {
|
||||||
|
try {
|
||||||
|
const out = await UserStory.findByIdAndDelete(req.params.id);
|
||||||
|
if (!out) return res.status(StatusCodes.NOT_FOUND).json({ error: "User story not found" });
|
||||||
|
res.status(StatusCodes.OK).json({ message: "User story deleted successfully" });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserStories(req, res) {
|
||||||
|
try {
|
||||||
|
const stories = await UserStory.find();
|
||||||
|
res.status(StatusCodes.OK).json(stories);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/models/comments.model.js
Normal file
25
backend/models/comments.model.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import mongoose, { model } from "mongoose";
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const CommentUserStorySchema = new Schema(
|
||||||
|
{
|
||||||
|
userStoryId: { type: Schema.Types.ObjectId, ref: "UserStory", required: true },
|
||||||
|
commentText: { type: String, required: true },
|
||||||
|
commentedBy: { type: String, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
CommentUserStorySchema.post("save", async function (doc, next) {
|
||||||
|
try {
|
||||||
|
await mongoose.model("UserStory").findByIdAndUpdate(
|
||||||
|
doc.userStoryId,
|
||||||
|
{ $addToSet: { comments: doc._id } } // $addToSet avoids duplicates
|
||||||
|
);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default model("Comment", CommentUserStorySchema);
|
||||||
20
backend/models/userStories.model.js
Normal file
20
backend/models/userStories.model.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import mongoose, { model } from "mongoose";
|
||||||
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
|
const UserStorySchema = new Schema(
|
||||||
|
{
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String, required: true },
|
||||||
|
status: { type: String, enum: ["Todo", "In-Review", "Sprint-Ready"], default: "Todo" },
|
||||||
|
businessValue: { type: Number, required: true, min: 1, max: 100 },
|
||||||
|
storyPoint: { type: Number, required: true, enum: [1, 2, 3, 5, 8, 13, 21, 34, 55] },
|
||||||
|
assignedTo: { type: String },
|
||||||
|
comments: [{ type: Schema.Types.ObjectId, ref: "Comment" }],
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
//Sorting by most recent
|
||||||
|
UserStorySchema.index({ createdAt: -1 });
|
||||||
|
|
||||||
|
export default model("UserStory", UserStorySchema);
|
||||||
24
backend/package.json
Normal file
24
backend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"http-status-codes": "^2.3.0",
|
||||||
|
"mongoose": "^8.19.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/routes/comments.route.js
Normal file
19
backend/routes/comments.route.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
createComment,
|
||||||
|
updateComment,
|
||||||
|
deleteComment,
|
||||||
|
} from "../controllers/comments.controller.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// POST /api/comments
|
||||||
|
router.post("/", createComment);
|
||||||
|
|
||||||
|
// PUT /api/comments/:id
|
||||||
|
router.put("/:id", updateComment);
|
||||||
|
|
||||||
|
// DELETE /api/comments/:id
|
||||||
|
router.delete("/:id", deleteComment);
|
||||||
|
|
||||||
|
export default router;
|
||||||
18
backend/routes/userStories.route.js
Normal file
18
backend/routes/userStories.route.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
createUserStories,
|
||||||
|
getUserStory,
|
||||||
|
updateUserStory,
|
||||||
|
deleteUserStory,
|
||||||
|
listUserStories,
|
||||||
|
} from "../controllers/userStories.controller.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", listUserStories);
|
||||||
|
router.post("/", createUserStories);
|
||||||
|
router.get("/:id", getUserStory);
|
||||||
|
router.put("/:id", updateUserStory);
|
||||||
|
router.delete("/:id", deleteUserStory);
|
||||||
|
|
||||||
|
export default router;
|
||||||
35
backend/server.js
Normal file
35
backend/server.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import express from "express";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import cors from "cors";
|
||||||
|
|
||||||
|
import userStoriesRoutes from "./routes/userStories.route.js";
|
||||||
|
import commentsRoutes from "./routes/comments.route.js";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// MongoDB connection
|
||||||
|
mongoose.connect(process.env.MONGO_URI)
|
||||||
|
.then(() => console.log("MongoDB connected"))
|
||||||
|
.catch(err => console.log(err));
|
||||||
|
|
||||||
|
// Health check route
|
||||||
|
app.get("/health", (req, res) => {
|
||||||
|
res.status(200).type("text/plain").send("Server works");
|
||||||
|
console.log("Server works")
|
||||||
|
});
|
||||||
|
|
||||||
|
// User Stories routes
|
||||||
|
app.use("/api/v1/user-stories", userStoriesRoutes);
|
||||||
|
|
||||||
|
// User stories comments routes
|
||||||
|
app.use("/api/v1/comments", commentsRoutes);
|
||||||
|
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const PORT = process.env.PORT || 5050;
|
||||||
|
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||||
21
frontend/.eslintrc.cjs
Normal file
21
frontend/.eslintrc.cjs
Normal 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
25
frontend/.gitignore
vendored
Normal 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
8
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
34
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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
29
frontend/src/App.jsx
Normal 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;
|
||||||
BIN
frontend/src/assets/fonts/Poppins-Regular.ttf
Normal file
BIN
frontend/src/assets/fonts/Poppins-Regular.ttf
Normal file
Binary file not shown.
0
frontend/src/assets/imgs/dummy
Normal file
0
frontend/src/assets/imgs/dummy
Normal file
280
frontend/src/components/comments/CommentsModal.jsx
Normal file
280
frontend/src/components/comments/CommentsModal.jsx
Normal 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;
|
||||||
29
frontend/src/components/common/ToastContainer.jsx
Normal file
29
frontend/src/components/common/ToastContainer.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
9
frontend/src/components/common/loaders/ClassicLoader.jsx
Normal file
9
frontend/src/components/common/loaders/ClassicLoader.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import "@/styles/Loaders.css"
|
||||||
|
|
||||||
|
const ClassicLoader = () => {
|
||||||
|
return (
|
||||||
|
<div className="classic-loader"></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClassicLoader
|
||||||
18
frontend/src/components/layout/loaders/GlobalLoader.jsx
Normal file
18
frontend/src/components/layout/loaders/GlobalLoader.jsx
Normal 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
|
||||||
183
frontend/src/components/layout/navbar/Navbar.jsx
Normal file
183
frontend/src/components/layout/navbar/Navbar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
frontend/src/components/user-stories/UserStoryForm.jsx
Normal file
187
frontend/src/components/user-stories/UserStoryForm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/context/LoaderContext.jsx
Normal file
22
frontend/src/context/LoaderContext.jsx
Normal 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>
|
||||||
|
}
|
||||||
73
frontend/src/context/ToastContext.jsx
Normal file
73
frontend/src/context/ToastContext.jsx
Normal 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
13
frontend/src/main.jsx
Normal 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>,
|
||||||
|
)
|
||||||
169
frontend/src/pages/UserStories.jsx
Normal file
169
frontend/src/pages/UserStories.jsx
Normal 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;
|
||||||
13
frontend/src/plugins/Http.js
Normal file
13
frontend/src/plugins/Http.js
Normal 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
|
||||||
59
frontend/src/services/apis/comments.js
Normal file
59
frontend/src/services/apis/comments.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
101
frontend/src/services/apis/userStories.js
Normal file
101
frontend/src/services/apis/userStories.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
19
frontend/src/styles/App.css
Normal file
19
frontend/src/styles/App.css
Normal 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;
|
||||||
|
}
|
||||||
21
frontend/src/styles/Loaders.css
Normal file
21
frontend/src/styles/Loaders.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/styles/index.css
Normal file
23
frontend/src/styles/index.css
Normal 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;
|
||||||
|
}
|
||||||
0
frontend/src/utils/GlobalVariables.js
Normal file
0
frontend/src/utils/GlobalVariables.js
Normal file
67
frontend/tailwind.config.js
Normal file
67
frontend/tailwind.config.js
Normal 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
12
frontend/vite.config.js
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "sleekboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "This project consists of a **Node.js backend** and a **React frontend**, both located in their respective subdirectories.",
|
||||||
|
"homepage": "https://github.com/nmoham46/SleekBoard#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/nmoham46/SleekBoard/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/nmoham46/SleekBoard.git"
|
||||||
|
},
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "concurrently \"npm run dev --prefix frontend\" \"npm run start --prefix backend\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user