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