diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..120b985 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules +.env +package-lock.json + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2f953f --- /dev/null +++ b/README.md @@ -0,0 +1,2457 @@ +# Beehive Monitor Web Application + +This full stack web application (_MERN_ Stack), was built to service the Santa Clara University Senior Design engineering project: **A Hardware Solution to Wireless Beehive Monitoring** + +The frontend is built using _React_, _Redux_, and _Axios_; the backend is built using _Express_ and _Node_; and the database is managed in _MongoDB_. The frontend heavily utilizes the open-source _React_ component library, _Material UI_ for styling and design. The backend utilizes _Mongoose_ for database modeling and management. + +This project aims to rely solely on _open-source_ technologies to ensure its reliability and longevity for beekeepers around the world. + +# Table of Contents + +- [Beehive Monitor Web Application](#beehive-monitor-web-application) +- [Table of Contents](#table-of-contents) +- [Basic Information](#basic-information) + - [Installation](#installation) + - [Note](#note) + - [Configuration](#configuration) + - [Usage](#usage) +- [Backend](#backend) + - [Overview](#overview) +- [Config](#config) + - [Overview](#overview-1) + - [`db.js`](#dbjs) +- [Server](#server) + - [Overview](#overview-2) + - [`server.js`](#serverjs) +- [Middleware](#middleware) + - [Overview](#overview-3) + - [`auth.middleware.js`](#authmiddlewarejs) + - [`errorHandler.middleware.js`](#errorhandlermiddlewarejs) +- [Models](#models) + - [Overview](#overview-4) + - [`user.model.js`](#usermodeljs) + - [`apiary.model.js`](#apiarymodeljs) + - [`data.model.js`](#datamodeljs) +- [Routes](#routes) + - [Overview](#overview-5) + - [`user.routes.js`](#userroutesjs) + - [`apiary.routes`](#apiaryroutes) + - [`data.routes`](#dataroutes) +- [Controllers](#controllers-1) + - [`apiary.controller`](#apiarycontroller) + - [`data.controller`](#datacontroller) +- [Frontend](#frontend) + - [Overview](#overview-6) +- [`App.js`](#appjs) +- [`index.js`](#indexjs) +- [`index.css`](#indexcss) +- [`theme.js`](#themejs) +- [`store.js`](#storejs) +- [Components](#components) + - [`AboutCard` React Component](#aboutcard-react-component) + - [`AddApiaryCard` React Component](#addapiarycard-react-component) + - [`AddDeviceCard` React Component](#adddevicecard-react-component) + - [`AddUserCard` React Component](#addusercard--react-component) + - [`ApiaryCard` React Component](#apiarycard-react-component) + - [`AutocompleteMaps` Typescript Component](#autocompletemaps-typescript-component) + - [`CustomTooltip` React Component](#customtooltip-react-component) + - [`DeviceCard` React Component](#-devicecard-react-component) + - [`FAQCard` React Component](#faqcard-react-component) + - [`Graph` React Component](#graph-react-component) + - [`Loading` React Component](#loading-react-component) + - [`Overview` React Component](#overview-react-component) + - [`SelectApiary` React Component](#selectapiary-react-component) + - [`Sidebar` React Component](#sidebar-react-component) + - [`Topbar` React Component](#topbar-react-component) + - [`UserCard` React Component](#usercard-react-component) +- [Features](#features) + - [Apiary](#apiary) + - [`apiary.slice`](#apiaryslice) + - [`apiary.service`](#apiaryservice) + - [Auth](#auth) + - [`auth.slice`](#authslice) + - [`auth.service`](#authservice) +- [Pages](#pages) + - [`About` React Page](#about-react-page) + - [`Dasboard` React Page](#dashboard-react-page) + - [`FAQ` React Page](#faq-react-page) + - [`Login` React Page](#login-react-page) + - [`Manage` React Page](#manage-react-page) + - [`Register` React Page](#register-react-page) +- [Deployment](#deployment) +- [Testing](#testing) +- [License](#license) +- [Acknowledgments](#acknowledgments) +- [Appendix](#appendix) +- [Screenshots](#screenshots) + - [Sign Up Page](#sign-up-page) + - [Sign In Page](#sign-in-page) + - [Dashboard Page](#dashboard-page) + - [Manage Page](#manage-page) + - [About Page](#about-page) + - [FAQ Page](#faq-page) +- [Video Demo](#video-demo) +- [Contact](#contact) + +Table of contents generated with markdown-toc + +# Basic Information + +## Installation + +To install the necessary dependencies for the frontend and backend, run the following command in the main directory: + +```bash +npm run yayaya +``` + +To install the necessary dependencies for the backend only, run the following command in the main directory: + +```bash +npm install +``` + +To install the necessary dependencies for the frontend only, run the following command in the `./frontend` directory: + +```bash +npm install +``` + +### Note + +If you run into funding errors, try adding `--force` to the end of your command, and/or running the installations separately. + +## Configuration + +Use the template `.env` file to setup necessary configurations such as the MongoDB URI, JWT secret, Google Maps Locations API, and port. For those continuing this project, please contact cpaiz@scu.edu or paizcollin@gmail.com for details or assistance. + +## Usage + +To start the frontend and backend, run the following command in the main directory: + +```bash +npm run dev +``` + +To start the backend only, run the following command in the main directory: + +```bash +npm run server +``` + +To start the frontend only, run the following command in the main directory: + +```bash +npm run client +``` + +The frontend will run on port 3000, and the backend will run on port 5000., by default. + +Using the Web Application is simple. The user will be greeted with a login page. If the user has an account, they can login with their credentials. If the user does not have an account, they can register for one. Once logged in, the user will be greeted with a dashboard. The dashboard will display the user's beehives, and the user can use the dropdown to select an apiary and subsequent beehive device to view more information about that specific hive. The user can also add, delete, and update apiaries, each of which is a small organization that contains its own set of devices (hives) that can be added, deleted, and updated. Users may also be added to apiaries to help manage them; members of apiaries can be granted different privileges, with the creator of the apiary having full control of the apiary and its hives. The user can also view the FAQ page, which will display frequently asked questions about the application, as well as the about page, which will display information about the application and its creators. The user can also logout of the application. + +# Backend + +## Overview + +The backend is neatly organized into individual modules, each of which is responsible for a specific set of tasks. The modules are as follows: `config`, `server`, `middleware`, `models`, `routes`, and `server`. Each module is described in detail below. + +# Config + +## Overview + +The `config` module is responsible for setting up the _MongoDB_ connection using Mongoose and the `ATLAS_URI` provided by the _MongoDB_ database. + +## `db.js` + +### Description + +This file is responsible for connecting the application to the _MongoDB_ database using the `ATLAS_URI` provided by the _MongoDB_ database. The `ATLAS_URI` is stored in the `.env` file, which is not included in the repository for security reasons. The `ATLAS_URI` is a string that contains the username, password, and database name for the _MongoDB_ database. The `db.js` file uses the `ATLAS_URI` to connect to the database using the `mongoose.connect()` method. + +### Usage Example + +```javascript +const connectDB = require("./connectDB"); + +// Call the function +connectDB(); +``` + +In this example, the `connectDB` function is imported from the `db.js` file and called. This will connect your application to the database using the `MongoDB URI` provided in the `.env` file. + +# Server + +## Overview + +The `server` is responsible for setting up the server and connecting to the _MongoDB_ database. The server is also responsible for listening on the port specified in the `.env` file. + +## `server.js` + +### Description + +This _Node.js_ sets up an _HTTP_ server using the _Express_ framework. It exports a single function that starts the server and listens for incoming requests. + +### Usage Example + +```javascript +const startServer = require("./server"); + +// Call the function +startServer(); +``` + +In this example, the `startServer` function is imported from the `server.js` file and called. This will start the server and listen for incoming requests. + +# Middleware + +## Overview + +The `middleware` module is responsible for setting up the middleware for the application. + +## `auth.middleware.js` + +### Description + +This is a middleware function that adds authentication to a _Node.js/Express_ application. The `protect` function checks for a valid _JSON Web Token (JWT)_ in the `Authorization` header of incoming _HTTP_ requests. If a valid token is found, the function decodes the token and sets the authenticated user in the request object. If a valid token is not found, the function returns a **401 Unauthorized response**. + +### Usage + +The `protect` function can be used as a middleware function in any _Express_ route that requires authentication. Here's an example of how to use the `protect` middleware function in an _Express_ route: + +```javascript +const express = require("express"); +const protect = require("../middleware/protect"); + +const router = express.Router(); + +router.get("/", protect, (req, res) => { + res.json({ user: req.user }); +}); +``` + +In this example, the `protect` middleware function is used as the second argument in the `router.get()` method. This means that the `protect` function will be called before the route handler function. If the _JWT_ is valid and the user is found, the `req.user` property will be set to the user object without the password field, and the route handler function will be called. If the _JWT_ is not valid or not provided, a **401 Unauthorized response** will be returned. + +### Function Parameters + +| Parameter | Description | +| --------- | --------------------------------------------------------------------------------------------- | +| `req` | The incoming HTTP request object. | +| `res` | The outgoing HTTP response object. | +| `next` | A function that passes control to the next middleware function in the request-response cycle. | + +### Function Flow + +The `protect` function follows this flow: + +1. Check if the `Authorization` header exists and starts with the word "Bearer". +2. If the header exists and starts with "Bearer", try to verify the _JWT_. +3. If the _JWT_ is valid, get the user ID from the token payload and find the user in the database. +4. If the user is found, set the `req.user` property to the user object without the password field, and call the `next()` function to pass control to the next middleware. +5. If the _JWT_ is not valid, return a **401 Unauthorized response** with an error message. +6. If the `Authorization` header does not exist or does not start with "Bearer", return a **401 Unauthorized response** with an error message. + +## `errorHandler.middleware.js` + +### Description + +This is a middleware function that adds error handling to a _Node.js/Express_ application. The `errorHandler` function catches any errors that occur in the application and sends an appropriate error response to the client. + +### Usage Example + +The `errorHandler` function should be used as the last middleware function in the middleware stack of an _Express_ application. Here's an example of how to use the `errorHandler` middleware function in an _Express_ application: + +```javascript +const express = require("express"); +const errorHandler = require("../middleware/errorHandler"); + +const app = express(); + +// Set up middleware functions +app.use(express.json()); +app.use(cors()); + +// Set up routes +app.get("/", (req, res) => { + res.send("Hello, world!"); +}); + +// Set up error handler middleware +app.use(errorHandler); + +// Start server +app.listen(3000, () => { + console.log("Server started on port 3000"); +}); +``` + +In this example, the `errorHandler` middleware function is added to the middleware stack using the `app.use()` method. This means that the `errorHandler` function will be called if any middleware or route handler functions before it throw an error. The `errorHandler` function sends an error response with the error message and stack trace (if in development mode) to the client. + +| Parameter | Description | +| --------- | --------------------------------------------------------------------------------------------- | +| `err` | The error object that was thrown by a previous middleware or route handler function. | +| `req` | The incoming HTTP request object. | +| `res` | The outgoing HTTP response object. | +| `next` | A function that passes control to the next middleware function in the request-response cycle. | + +### Function Flow + +The `errorHandler` function follows this flow: + +1. Get the status code from the `res` object. If the status code is not set, default to 500 (Internal Server Error). +2. Set the HTTP status code of the response to the status code obtained in the previous step. +3. Send a JSON response to the client with the error message and stack trace (if in development mode). + +### Error Handling + +The `errorHandler` function is itself an error handling middleware function, and its purpose is to catch errors thrown by previous middleware or route handler functions. If an error is thrown by a previous function, the `errorHandler` function will catch the error and send an appropriate error response to the client. The error message is sent as the `message` property of the JSON response, and the stack trace (if in development mode) is sent as the `stack` property of the JSON response. + +# Models + +## Overview + +The three models used in the application are the `User`, `Apiary`, and `Data` models. The `User` model is used to store user data in the database, the `Apiary` model is used to store apiary data in the database. and the `Data` model is used to store data from the devices in the database. + +## `user.model.js` + +### Description + +This is a schema definition for the `user` model in a _Node.js/Express_ application using _Mongoose_. The `userSchema` defines the structure and validation rules for user documents that will be stored in a _MongoDB_ database. + +### `userSchema` + +| Field | Type | Required | Unique | Example Value | Description | +| --------- | -------- | -------- | ------ | ---------------------- | ------------------------------------------------ | +| name | `String` | Yes | No | "John Doe" | The user's name. | +| email | `String` | Yes | Yes | "johndoe@example.com" | The user's email address. Must be unique. | +| password | `String` | Yes | No | "$2a$10$V7X9I...jMx7" | The user's password. | +| createdAt | `Date` | No | No | "2023-04-25T12:34:56Z" | The timestamp for when the user was created | +| updatedAt | `Date` | No | No | "2023-04-25T12:34:56Z" | The timestamp for when the user was last updated | + +Note that the `createdAt` and `updatedAt` fields are of type `Date` and are automatically generated by Mongoose using the `timestamps` option set to `true` in the schema. + +### Example Document + +```json +{ + "_id": "6153db097a880f72a2d6c853", + "name": "John Doe", + "email": "johndoe@example.com", + "password": "$2a$10$YSiN2VK/OdtZ5N5c5S5S5OAmQ2IyHz.gxCGKdDw.Ggchc20OaG1Ia", + "createdAt": "2021-09-28T05:45:29.586Z", + "updatedAt": "2021-09-28T05:45:29.586Z" +} +``` + +Note that the password field is encrypted using _bcrypt_, so the value shown is just an example of an encrypted password hash. The actual value would be a long string of random characters. + +## `apiary.model.js` + +### Description + +This is a schema definition for the `apiary.model` in a _Node.js/Express_ application using _Mongoose_. The `apiarySchema` defines the structure and validation rules for apiary documents that will be stored in a MongoDB database. This model consists of a `geoSchema` which defines a location, a `memberSchema` which defines a list of users, a `deviceSchema` which defines a list of devices, and an `apiarySchema` which defines an apiary. + +### `geoSchema` + +| Field | Type | Required | Unique | Example Value | Description | +| ---------------- | ----------------- | -------- | ------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| type | `String` | No | No | "Point" | The type of the location data. Default value is "Point". | +| coordinates | Array of `Number` | No | No | `[-73.9857, 40.7484]` | An array of longitude and latitude coordinates in that order. Indexed as a 2dsphere to enable location-based queries. | +| formattedAddress | `String` | No | No | "123 Main St, New York, NY" | The formatted address of the location. | +| placeID | `String` | No | No | "ChIJd8BlQ2BZwokRAFUEcm_qrcA" | The place ID of the location. | + +### `memberSchema` + +| Field | Type | Required | Unique | Example Value | Description | +| ----- | ---------- | -------- | ------ | -------------------------- | -------------------------------------------------------------------- | +| user | `ObjectId` | Yes | No | "61579f9a53e7e8f2916d47b1" | References a user object associated with the member | +| role | `String` | No | No | "USER" | The role of the member, must be one of "USER", "ADMIN", or "CREATOR" | + +### `deviceSchema` + +| Field | Type | Required | Unique | Example Value | Description | +| --------- | ---------- | -------- | ------ | -------------------------- | ---------------------------------------------------- | +| serial | `String` | Yes | Yes | "ABC123" | The serial number of the device | +| name | `String` | Yes | No | "Device 1" | The name of the device | +| remote | `String` | Yes | Yes | "https://remote.it/ABC123" | The remote.it URL of the device | +| data | `ObjectId` | No | No | "6154353e7eb3aa3b1886d051" | The ID of the data object associated with the device | +| createdAt | `Date` | No | No | "2022-01-01T00:00:00.000Z" | The date and time when the device was created | +| updatedAt | `Date` | No | No | "2022-01-02T00:00:00.000Z" | The date and time when the device was last updated | + +### `apiarySchema` + +| Field | Type | Required | Unique | Example Value | Description | +| --------- | --------------------------------------- | -------- | ------ | ------------------ | ---------------------------------------------- | +| name | String | No | No | "My Apiary" | The name of the Apiary | +| location | Object of type `geoSchema` | No | No | See `geoSchema` | The coordinates of the Apiary | +| members | Array of Objects of type `memberSchema` | Yes | No | See `memberSchema` | An array of members associated with the Apiary | +| devices | Array of Objects of type `deviceSchema` | No | No | See `deviceSchema` | An array of devices associated with the Apiary | +| createdAt | Date | No | No | - | The date when the Apiary was created | +| updatedAt | Date | No | No | - | The date when the Apiary was last updated | + +### Example Document + +```json +{ + "_id": "61701a1d45c5556f37e18f22", + "name": "My Apiary", + "location": { + "type": "Point", + "coordinates": [50.1234, -120.5678], + "formattedAddress": "123 Main Street, Vancouver, BC, Canada", + "placeID": "ChIJ2-3w3EXyhlQRugc5uVrXQ8o" + }, + "members": [ + { + "_id": "61701a1d45c5556f37e18f23", + "user": "616f5c5dc5be5e8b5fabc123", + "role": "ADMIN" + }, + { + "_id": "61701a1d45c5556f37e18f24", + "user": "616f5c5dc5be5e8b5fabc456", + "role": "USER" + } + ], + "devices": [ + { + "_id": "61701a1d45c5556f37e18f25", + "serial": "ABC123", + "name": "Sensor 1", + "remote": "https://example.com/ABC123", + "data": "61701a1d45c5556f37e18f26", + "createdAt": "2022-10-19T12:34:56.789Z", + "updatedAt": "2022-10-19T12:34:56.789Z" + }, + { + "_id": "61701a1d45c5556f37e18f27", + "serial": "DEF456", + "name": "Sensor 2", + "remote": "https://example.com/DEF456", + "data": null, + "createdAt": "2022-10-19T12:34:56.789Z", + "updatedAt": "2022-10-19T12:34:56.789Z" + } + ], + "createdAt": "2022-10-19T12:34:56.789Z", + "updatedAt": "2022-10-19T12:34:56.789Z" +} +``` + +## `data.model.js` + +### Description + +This is a schema definition for a `data.model` in a _Node.js/Express_ application using _Mongoose_. The `dataSchema` defines the structure and validation rules for data documents that will be stored in a _MongoDB_ database. This model consists of a `dataPointSchema` which defines the structure of an individual data point, and a `dataSchema` which defines a list of datapoints. + +### `dataPointSchema` + +The `dataPointSchema` defines the schema for an individual data point. It consists of the following fields: + +| Field | Type | Required | Unique | Example Value | Description | +| ------------------------- | -------- | -------- | ------ | ------------- | ---------------------------------------------------------------------------------- | +| time | `Date` | No | No | Date.now | The time the data point was recorded | +| raw_activity.x | `Number` | Yes | No | 5.2 | The x coordinate of the raw activity data | +| raw_activity.y | `Number` | Yes | No | 3.8 | The y coordinate of the raw activity data | +| weather.temp | `Number` | Yes | No | 25.4 | The temperature in Celsius at the time of recording | +| weather.humidity | `Number` | Yes | No | 70.2 | The humidity in percent at the time of recording | +| weather.windspeed | `Number` | Yes | No | 10.3 | The wind speed in meters per second at the time of recording | +| prediction_activity.x | `Number` | Yes | No | 2.1 | The x coordinate of the predicted activity data | +| prediction_activity.y | `Number` | Yes | No | 1.5 | The y coordinate of the predicted activity data | +| last_prediction_deviation | `Number` | No | No | 0.7 | The deviation between the raw and predicted activity data at the time of recording | + +### `dataSchema` + +The `dataSchema` defines the schema for a collection of data points. It consists of the following fields: + +| Field | Type | Required | Unique | Example Value | Description | +| ---------- | -------------------------- | -------- | ------ | -------------------------- | --------------------------------------------------- | +| apiary | `ObjectId` | No | No | "6154353e7eb3aa3b1886d051" | Reference to the Apiary model | +| serial | `String` | Yes | Yes | "ABC123" | Unique identifier of the device sending the data | +| datapoints | Array of `dataPointSchema` | No | No | See `dataPointSchema` | Array containing the data points sent by the device | + +### Example document + +```json +{ + "_id": "60934c7a2a58581ed82b8d96", + "apiary": "6051902568f4a4dbd4f1c0e5", + "serial": "XYZ123", + "datapoints": [ + { + "time": "2022-04-26T12:00:00.000Z", + "raw_activity": { "x": 10, "y": 20 }, + "weather": { "temp": 25, "humidity": 80, "windspeed": 10 }, + "prediction_activity": { "x": 12, "y": 22 }, + "last_prediction_deviation": 2.5 + }, + { + "time": "2022-04-26T12:05:00.000Z", + "raw_activity": { "x": 11, "y": 21 }, + "weather": { "temp": 26, "humidity": 81, "windspeed": 11 }, + "prediction_activity": { "x": 13, "y": 23 }, + "last_prediction_deviation": 2.0 + } + ], + "createdAt": "2021-05-06T00:00:00.000Z", + "updatedAt": "2021-05-06T00:00:00.000Z" +} +``` + +# Routes + +## Overview + +The `routes` module contains the route definitions for the _Node.js/Express_ application. The routes are organized into separate files based on their purpose. The `user.routes` file contains the routes for user authentication and authorization, the `apiary.routes` file contains the routes for the apiary endpoints, and the `data.routes` file contains the routes for the data endpoints. + +## `user.routes.js` + +### Description + +This file defines the routes for user authentication and authorization. The routes are protected by authentication middleware to ensure that only authenticated users can access them. + +### Route Endpoints + +| Endpoint | Request Type | Authentication | Description | +| --------------------- | ------------ | -------------- | -------------------------------------------------- | +| `/api/users/register` | `POST` | - | Registers a new user with the provided credentials | +| `/api/users/login` | `POST` | - | Logs in a user with the provided credentials | +| `/api/users/me` | `GET` | Protect | Gets the current user | + +## `apiary.routes` + +### Description + +This file defines the routes for the apiary related endpoints. The routes are protected by authentication middleware to ensure that only authenticated users can access them. + +### Route Endpoints + +| Endpoint | Request Type | Authentication | Description | +| ------------------------------------------------------------------------------- | ------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/api/apiaries` | `GET` | `Protect` | Returns all apiaries associated with the authenticated user. (no data) | +| `/api/apiaries/filter/:filterid` | `GET` | `Protect` | Returns all apiaries associated with the authenticated user. (includes device data filtered by time range; limited to 1000 datapoints for request efficiency) | +| `/api/apiaries` | `POST` | `Protect` | Creates a new apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id` | `PUT` | `Protect` | Updates the specified apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id` | `DELETE` | `Protect` | Deletes the specified apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id/setdevice` | `PUT` | `Protect` | Sets a new device for the specified apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id/device/:device_id/updatedevice` | `PUT` | `Protect` | Updates the specified device for the specified apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id/device/:device_id/serial/:serial/deletedevice` | `PUT` | `Protect` | Deletes the specified device for the specified apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id/setmember` | `PUT` | `Protect` | Sets a new member for the specified apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id/user/:user_id/updatemember` | `PUT` | `Protect` | Updates the specified member for the specified apiary associated with the authenticated user. | +| `/api/apiaries/apiary/:apiary_id/user/:user_id/deletemember` | `PUT` | `Protect` | Deletes the specified member for the specified apiary associated with the authenticated user. | + +## `data.routes` + +### Description + +This file defines the routes for the data related endpoints. + +### Controllers + +The `controllers` directory contains the functions that handle the logic for each route. + +### Route Endpoints + +| Endpoint | Request Type | Authentication | Description | +| -------------------------- | ------------ | -------------- | --------------------------------------------------------------------------------- | +| `/api/data/serial/:serial` | `PUT` | None | Puts the data from the device with the specified serial number into the database. | + +# Controllers + +## `user.controller` + +This file handles user-related routes and requests. + +### Functions + +| Function | Route | Access | Description | +| ------------- | ----------------------- | ------- | -------------------------------------------------------------------------------- | +| registerUser | `POST /api/users` | Public | Registers a new user. | +| loginUser | `POST /api/users/login` | Public | Authenticates a user and generates a _JSON Web Token_ (_JWT_) for authorization. | +| getMe | `GET /api/users/me` | Private | Gets user data based on the ID of the authenticated user. | +| generateToken | - | - | Generates a _JSON Web Token_ (_JWT_) for authorization. | + +## `apiary.controller` + +### Description + +This file handles apiary-related routes and requests, including creating, updating, and deleting apiaries; setting, updating, and deleting devices; and setting, updating, and deleting members. + +### Functions + +Here is the updated table with the new GET request: + +| Function | Route | Access | Description | +| ----------------------- | ----------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| checkUserToApiary | - | Private; apiary admins | Checks if the logged-in user is a member of the specified apiary and has admin privileges. | +| getApiaries | `GET /api/apiaries` | Private; all users | Retrieves all apiaries associated with the currently logged-in user. | +| getApiaryWithDeviceData | `GET /api/apiaries/filter/:filter` | Private; all users | Retrieves apiaries with device data based on the specified date filter. Data is limited to 1000 datapoints for speed and efficiency. | +| setApiary | `POST /api/apiaries` | Private; all users | Creates a new apiary. | +| updateApiary | `PUT /api/apiaries/apiary/:apiary_id` | Private; apiary admins | Updates the specified apiary's name and location. | +| deleteApiary | `DELETE /api/apiaries/apiary/:apiary_id` | Private; apiary creator | Deletes the specified apiary and all associated data. | +| setDevice | `PUT /api/apiaries/apiary/:apiary_id/setdevice` | Private; apiary admins | Sets a new device to an apiary. | +| updateDevice | `PUT /api/apiaries/apiary/:apiary_id/device/:device_id/updatedevice` | Private; apiary admins | Updates an existing device in an apiary. | +| deleteDevice | `PUT /api/apiaries/apiary/:apiary_id/device/:device_id/serial/:serial/deletedevice` | Private; apiary admins | Deletes an existing device from an apiary. | +| setMember | `PUT /api/apiaries/apiary/:apiary_id/setmember` | Private; apiary admins | Updates the user's role in the apiary to Editor. | +| updateMember | `PUT /api/apiaries/apiary/:apiary_id/user/:user_id/updatemember` | Private; apiary admins | Updates the user's role in the apiary to Owner. | +| deleteMember | `PUT /api/apiaries/apiary/:apiary_id/user/:user_id/deletemember` | Private; apiary admins | Deletes the specified user from the apiary. | + +## `data.controller` + +### Description + +This file handles data-related routes and requests. It includes uploading data points to the database. + +### Functions + +| Function | Route | Access | Description | +| -------- | ------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------- | +| putData | `POST /api/data/serial/:serial` | Needs protection (ML team authorized only) | Uploads a data point to the database based on the provided serial number. | + +# Frontend + +## Overview + +The frontend of the application is built using _React_. The `src` directory contains the source code for the frontend, including the components, pages, stylsheets, and features. + +# `App.js` + +## Description + +The `App.js` component is the root component of the application. It is responsible for rendering the application's navigation bar and routing the user to the appropriate page based on the URL. + +# `index.js` + +## Description + +The `index` file is the entry point of the application. It renders the `App` component and wraps it in a `BrowserRouter` component to enable routing. + +# `index.css` + +## Description + +The `index.css` file contains the global styles for the application. + +## Fonts + +This stylesheet imports the "Poppins" font family from Google Fonts with three weights - 400 (regular), 600 (semi-bold), and 700 (bold) - and the display property set to "swap". + +```css +@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap"); +``` + +## Height, Width and Font + +The stylesheet applies 100% height and width to the `html`, `body`, `#root`, `.app`, and `.content` selectors, and uses the "Poppins" font family as the default font for these elements. + +```css +html, +body, +#root, +.app, +.content { + height: 100%; + width: 100%; + font-family: "Poppins", sans-serif; +} +``` + +## App Display and Position + +The `.app` selector has its display set to "flex" and position set to "relative". + +```css +.app { + display: flex; + position: relative; +} +``` + +## Scrollbar Styling + +The stylesheet applies custom styling to the scrollbar using the WebKit CSS scrollbar pseudo-elements. The width of the scrollbar is set to 10px, the track color is set to #e0e0e0, and the handle color is set to #888. On hover, the track color changes to #555. + +```css +::-webkit-scrollbar { + width: 10px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: #e0e0e0; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #888; +} + +/* Handle on Hover */ +::-webkit-scrollbar-track:hover { + background: #555; +} +``` + +## Fieldset Styling + +The `fieldset` selector has its border and outline set to "none" to remove any default styling applied to the element. + +```css +fieldset { + border: none !important; + outline: none !important; +} +``` + +That's it! This is a basic documentation of the CSS stylesheet provided. + +# `theme.js` + +## Description + +The `theme` file contains the color design tokens and MUI theme settings for the application. It exports two functions, `tokens` and `themeSettings`, which can be used to retrieve the color design tokens and MUI theme settings for the specified mode. + +## `tokens` + +The `tokens` function accepts a single argument, `mode`, which can either be `"light"` or `"dark"`, and returns an object containing the color design tokens for the specified mode. + +### Example Usage + +```jsx +import { tokens } from "./theme"; + +const darkModeTokens = tokens("dark"); +const lightModeTokens = tokens("light"); +``` + +## `themeSettings` + +The `themeSettings` function accepts a single argument, `mode`, which can either be `"light"` or `"dark"`, and returns an object containing the MUI theme settings for the specified mode. + +### Example Usage + +```jsx +import { createTheme } from "@mui/material/styles"; +import { themeSettings } from "./theme"; + +const darkTheme = createTheme(themeSettings("dark")); +const lightTheme = createTheme(themeSettings("light")); +``` + +# `store.js` + +## Description + +The `store` file configures a Redux store using `@reduxjs/toolkit`. It uses two slices, `auth` and `apiary`, to manage state related to user authentication and the application's apiary feature. + +# Components + +## `AboutCard` React Component + +### Description + +The `AboutCard` component is a reusable component that displays an information card with a title, avatar, and expandable content. It is used in the `About` page to display information regarding the project. + +### Props + +| Name | Type | Required | Description | +| ---- | ------------------- | -------- | ---------------------------------------------------------------------------------------------------- | +| faq | `List` of `Strings` | Yes | An array of two strings: the title of the card and the content to display when the card is expanded. | + +### State + +| Name | Type | Description | +| ------ | --------- | ----------------------------------------------------- | +| expand | `Boolean` | Determines whether the card is expanded or collapsed. | + +### Methods + +The `AboutCard` component does not define any methods. + +### Handlers + +The `AboutCard` component does not define any handlers. + +### Child Components + +| Name | Package | Description | +| ------------------------- | --------------------- | --------------------------------------------------------------- | +| ArrowDropDownOutlinedIcon | `@mui/icons-material` | An icon component that represents a downward-pointing arrow | +| Avatar | `@mui/material` | A component that represents a user or entity | +| Box | `@mui/material` | A component that provides a flexible container for content | +| Card | `@mui/material` | A container component that displays information | +| CardActions | `@mui/material` | A component that provides space for buttons or icons | +| CardHeader | `@mui/material` | A component that displays a header for a card | +| Collapse | `@mui/material` | A component that animates the expansion or collapse of content | +| Grid | `@mui/material` | A layout component that helps to organize content in a grid | +| IconButton | `@mui/material` | A button component that displays an icon | +| InfoOutlinedIcon | `@mui/icons-material` | An icon component that represents an information icon | +| Typography | `@mui/material` | A component that displays text content | +| tokens | Custom function | A function that returns color tokens based on the current theme | +| useTheme | `@mui/material` | A hook that provides access to the current theme | +| useState | `react` | A hook that adds state to a functional component | + +### Usage Example + +```jsx +import AboutCard from "./AboutCard"; + +const ExampleComponent = () => { + const faq = ["Title", "Content"]; + + return ; +}; +``` + +In this example, we import the `AboutCard` component and render it with an array of two strings that will be displayed as the title and content of the card. When the user clicks on the arrow button in the card header, the content of the card will expand or collapse. + +## `AddApiaryCard` React Component + +### Description + +The `AddApiaryCard` component is a form card used to create a new apiary in a _React_ application. It includes a name input field, a location input field powered by the `GoogleMaps` component, and a button to submit the form data. + +### Props + +The `AddApiaryCard` component does not accept any props. + +### State + +| Name | Type | Description | +| -------- | --------- | ---------------------------------------------------------------------------------- | +| value | `Object` | The selected location value from the GoogleMaps component. | +| expand | `Boolean` | Whether or not the card is expanded. | +| formData | `Object` | The form data to be submitted, including the apiary name and location information. | + +### Methods + +The `AddApiaryCard` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| -------- | ------------ | -------------------------------------------------------------------- | +| onChange | `e`: `Event` | Updates the form data when input fields are changed. | +| onSubmit | `e`: `Event` | Submits the form data to the Redux store via the `setApiary` action. | + +### Child Components + +| Name | Package | Description | +| --------------- | ----------------------------- | --------------------------------------------------------------------- | +| AddOutlinedIcon | `@mui/icons-material` | A material design add icon. | +| Autocomplete | `@mui/material` | A component to create an input field with autocomplete functionality. | +| Avatar | `@mui/material` | A component to display user profile pictures or icons. | +| Box | `@mui/material` | A layout component to create a box container. | +| Button | `@mui/material` | A material design button component. | +| Card | `@mui/material` | A material design card component. | +| CardActions | `@mui/material` | A component to create a row of actions for a `Card` component. | +| Collapse | `@mui/material` | A component to collapse content based on a boolean state variable. | +| debounce | `lodash` | A function to debounce input field changes. | +| GoogleMaps | `./AutocompleteMaps.tsx` | A custom component to display a Google Maps autocomplete input field. | +| Grid | `@mui/material` | A layout component to create a grid of items. | +| IconButton | `@mui/material` | A component to create clickable icons. | +| parse | `autosuggest-highlight/parse` | A function to parse and highlight text in an autocomplete input. | +| TextField | `@mui/material` | A material design input component. | +| useTheme | `@mui/material` | A hook to access the current theme. | +| useDispatch | `react-redux` | A hook to dispatch actions to the Redux store. | +| useState | `react` | A hook to manage state variables in a functional component. | + +### Usage Example + +```jsx +import AddApiaryCard from "./AddApiaryCard"; + +const ParentComponent = () => { + return ( +
+ +
+ ); +}; + +export default ParentComponent; +``` + +In this example, when the form is submitted, the data is sent to the Redux store via the `setApiary` action. This data includes the apiary name and location information provided by the `GoogleMaps` component. + +## `AddDeviceCard` React Component + +### Description + +This component renders a card with a form to add a new device to an apiary. The form includes an input field for the device name, a select field for the device type, and a button to submit the form data. + +### Props + +| Name | Type | Required | Description | +| -------- | -------- | -------- | ------------------------------------------------------------ | +| apiary | `Object` | Yes | An object with data for the apiary where the device is added | +| userRole | `String` | Yes | A string with the user role, either "USER" or "ADMIN" | + +### State + +| Name | Type | Description | +| -------- | --------- | -------------------------------------------------------- | +| expand | `Boolean` | A boolean indicating whether the card is expanded or not | +| formData | `Object` | An object containing the values of the form inputs | + +### Methods + +The `AddDeviceCard` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| -------- | ------------ | ---------------------------------------------------------- | +| onChange | `e`: `Event` | A function to handle the onChange event of the form inputs | +| onSubmit | `e`: `Event` | A function to handle the onSubmit event of the form | + +### Child Components + +| Name | Package | Description | +| --------------- | --------------------- | ------------------------------------------------------------------------------------- | +| AddOutlinedIcon | `@mui/icons-material` | An icon component that displays a plus symbol. | +| Avatar | `@mui/material` | A component for displaying a circular image or icon. | +| Box | `@mui/material` | A component for wrapping and styling its children. | +| Button | `@mui/material` | A component for displaying a clickable button. | +| Card | `@mui/material` | A container component for displaying content and actions related to a single subject. | +| CardActions | `@mui/material` | A container component for grouping action buttons in a card. | +| Collapse | `@mui/material` | A component that allows for collapsing and expanding its children. | +| Grid | `@mui/material` | A layout component for arranging its children in a grid. | +| IconButton | `@mui/material` | A button component that displays an icon. | +| TextField | `@mui/material` | A component for displaying and inputting text. | +| useDispatch | `react-redux` | A hook for dispatching actions to the Redux store. | +| useSelector | `react-redux` | A hook for accessing state from the Redux store. | +| useNavigate | `react-router-dom` | A hook for navigating to different routes in a React application. | +| useTheme | `@mui/material` | A hook that provides access to the MUI theme. | +| useState | `React` | A hook for managing state in functional components. | + +### Usage Example + +```jsx +import React from "react"; +import AddDeviceCard from "./AddDeviceCard"; + +const MyComponent = () => { + return ( + + ); +}; + +export default MyComponent; +``` + +In this example, `MyComponent` renders the `AddDeviceCard` component passing the required props `apiary` and `userRole`. The `apiary` prop is an object with the data for the apiary where the device will be added, and the `userRole` prop is a string indicating the role of the user, either "USER" or "ADMIN". + +## `AddUserCard` React Component + +### Description + +The `AddUserCard` component is a React component that displays a card with a button to expand a form to add a new user to an apiary. It takes in two props: `apiary` and `userRole`. + +### Props + +| Name | Type | Required | Description | +| -------- | -------- | -------- | ------------------------------------------- | +| apiary | `Object` | Yes | An object containing data for the APIary. | +| userRole | `String` | Yes | The role of the user creating the new user. | + +### State + +| Name | Type | Description | +| -------- | --------- | ---------------------------------------------------------- | +| expand | `Boolean` | Determines whether the form to add a new user is expanded. | +| formData | `Object` | An object containing the email and role of the new user. | + +### Methods + +The `AddUserCard` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| -------- | ------------ | ---------------------------------------------------------------------------------------------------------------- | +| onChange | `e`: `Event` | Updates the `formData` state with the input value of the form. | +| onSubmit | `e`: `Event` | Dispatches an action to add a new member to the APIary with the email and role provided in the `formData` state. | + +### Child Components + +| Name | Package | Description | +| ---------------- | --------------- | ------------------------------------------------------------------------------------------ | +| Avatar | `@mui/material` | Displays an avatar with an icon. | +| Box | `@mui/material` | A layout component that allows for flexible box sizing. | +| Button | `@mui/material` | A button component with customizable styling. | +| Card | `@mui/material` | A component that displays a card with a shadow effect. | +| CardActions | `@mui/material` | A layout component that displays a set of buttons below the card content. | +| Checkbox | `@mui/material` | A component that displays a checkbox. | +| Collapse | `@mui/material` | A component that animates the expanding and collapsing of its children. | +| FormControlLabel | `@mui/material` | A component that combines a label with a form control, such as a checkbox or radio button. | +| Grid | `@mui/material` | A layout component that displays its children in a grid. | +| IconButton | `@mui/material` | A button component that displays an icon. | +| TextField | `@mui/material` | A component that displays an input field for text. | + +### Usage Example + +```jsx +import AddUserCard from "./AddUserCard"; + +const MyComponent = () => { + const apiary = { _id: "12345", name: "My APIary" }; + const userRole = "ADMIN"; + + return ; +}; +``` + +In this example, the `MyComponent` component renders the `AddUserCard` component with an `apiary` object and a `userRole` string. The `AddUserCard` component displays a card with a button to expand a form to add a new user to the APIary. + +## `ApiaryCard` React Component + +### Description + +The `ApiaryCard` component is a reusable component in a _React_ application that displays information about an apiary such as its `name`, `location` and other details. It allows users to edit, delete and update the apiary information. This component utilizes _Material-UI_ library. + +### Props + +The following table lists the props that can be passed to `ApiaryCard` component: + +| Name | Type | Required | Description | +| ------ | -------- | -------- | --------------------------------------- | +| apiary | `Object` | Yes | An object containing apiary information | + +### State + +The `ApiaryCard` component has the following states: + +| Name | Type | Description | +| -------- | --------- | ------------------------------------------------------------ | +| expand | `Boolean` | Tracks whether the form for editing the apiary should expand | +| formData | `Object` | Tracks the form data entered by the user for updating | + +### Methods + +The `ApiaryCard` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| -------- | ------------ | ----------------------------------------------- | +| onChange | `e`: `Event` | Updates the formData state on form input change | +| onSubmit | `e`: `Event` | Submits the form data to update the apiary | +| onDelete | `e`: `Event` | Deletes the apiary from the application | + +### Child Components + +| Name | Package | Description | +| --------------------- | --------------------- | ------------------------------------------------------------------------- | +| AddDeviceCard | `./AddDeviceCard` | A form for adding a new device to the apiary. | +| AddUserCard | `./AddUserCard` | A form for adding a new user to the apiary. | +| Avatar | `@mui/material` | A component for displaying a user's avatar. | +| Box | `@mui/material` | A layout component that displays child components in a box. | +| Button | `@mui/material` | A component for displaying a button. | +| Card | `@mui/material` | A component that displays a card. | +| CardActions | `@mui/material` | A component for displaying actions in a card. | +| CardHeader | `@mui/material` | A component for displaying a header in a card. | +| Collapse | `@mui/material` | A component for animating the expansion and collapse of a card's content. | +| DeviceCard | `./DeviceCard` | A card displaying details of a device in the apiary. | +| DeviceHubOutlinedIcon | `@mui/icons-material` | An icon component representing a device hub. | +| EditOutlinedIcon | `@mui/icons-material` | An icon component representing an edit button. | +| Grid | `@mui/material` | A layout component that displays child components in a grid. | +| GroupOutlinedIcon | `@mui/icons-material` | An icon component representing a group. | +| IconButton | `@mui/material` | A component for displaying an icon that can be clicked. | +| TextField | `@mui/material` | A component for displaying a text field that can be used for user input. | +| Typography | `@mui/material` | A component for displaying text. | +| useDispatch | `react-redux` | A hook that returns the Redux store's `dispatch` function. | +| useState | `react` | A hook that allows functional components to use component-level state. | + +### Usage + +```jsx +import { ApiaryCard } from "./components"; + +const apiary = { + _id: 1, + name: "My Apiary", + location: { + latitude: 43.6532, + longitude: -79.3832, + formattedAddress: "Toronto, ON, Canada", + }, + devices: [ + { + _id: 1, + name: "Device 1", + macAddress: "00:00:00:00:00:01", + }, + { + _id: 2, + name: "Device 2", + macAddress: "00:00:00:00:00:02", + }, + ], + members: [ + { + user: { + _id: 1, + name: "User 1", + email: "user1@example.com", + }, + role: "CREATOR", + }, + { + user: { + _id: 2, + name: "User 2", + email: "user2@example.com", + }, + role: "USER", + }, + ], +}; + +const App = () => { + return ; +}; +``` + +In this example, we will render an `ApiaryCard` component with the provided `apiary` object. Users can edit, delete and update the apiary details by expanding the form using the dropdown arrow button. + +## `AutocompleteMaps` Typescript Component + +### Description + +This component is a wrapper around the `Autocomplete` component from `MUI` that provides an autocomplete feature for locations using _Google Maps API_. The component suggests location results as the user types and selects a location from the options. + +### Props + +| Name | Type | Required | Description | +| -------- | ----------- | -------- | ---------------------------------------------- | +| value | `PlaceType` | Yes | The selected location value. | +| setValue | `function` | Yes | A function to set the selected location value. | + +### State + +| Name | Type | Description | +| ---------- | ---------------------- | --------------------------------------------------------------- | +| inputValue | `String` | The current input value of the Autocomplete component. | +| options | `readonly PlaceType[]` | An array of location options fetched from Google Maps API. | +| loaded | `Boolean` | Indicates whether the Google Maps API script has loaded or not. | + +### Methods + +The `AutocompleteMaps` component does not define any methods. + +### Handlers + +The `AutocompleteMaps` component does not define any handlers. + +### Interfaces + +| Name | Type | Description | +| ------------------------- | ----------- | -------------------------------------------------------------------------------- | +| MainTextMatchedSubstrings | `interface` | An interface representing the matched substrings in the main text of a location. | +| StructuredFormatting | `interface` | An interface representing the structured formatting of a location. | +| PlaceType | `interface` | An interface representing a location. | + +### Child Components + +| Component | Package | Description | +| -------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Autocomplete | `@mui/material` | A component that provides an input field with an autocomplete dropdown, which displays a list of options based on user input. | +| Box | `@mui/material` | A component used for creating layout containers that can contain other components. | +| Grid | `@mui/material` | A component used for creating grid-based layouts. | +| LocationOnIcon | `@mui/icons-material` | An icon component used for indicating a location. | +| TextField | `@mui/material` | An input field component used for accepting user input. | +| Typography | `@mui/material` | A component used for displaying text with different styles and variations. | + +### Usage Example + +```jsx +import GoogleMaps from "./AutocompleteMaps.tsx"; + +function MyComponent() { + const [value, setValue] = React.useState(null); + + return ; +} +``` + +In this example, the `value` prop is the currently selected place, and the `setValue` prop is a function that gets called with the selected place object when a new place is selected. You can pass these props to the `GoogleMaps` component and it will handle the rest. + +Note that you'll need to obtain a _Google Maps API_ key and enable the _Places API_ in order to use this component. You can do this by following the instructions in the [Google Maps JavaScript API documentation](https://developers.google.com/maps/gmp-get-started). + +## `CustomTooltip` React Component + +The `CustomTooltip` component is a custom tooltip component used for displaying additional information when hovering over a Rechart element. It receives `active`, `payload`, and `label` as props and renders the tooltip content accordingly, based on what metrics the Recharts graph is currently populated with. + +### Props + +| Name | Type | Required | Description | +| ------- | --------- | -------- | ------------------------------------------------------------- | +| active | `Boolean` | Yes | Determines whether the tooltip should be displayed or not. | +| payload | `Array` | Yes | An array of data points representing the values of the chart. | +| label | `String` | Yes | The label of the current data point being hovered. | + +### State + +The `CustomTooltip` component does not define any state. + +### Methods + +The `CustomTooltip` component does not define any methods. + +### Handlers + +The `CustomTooltip` component does not define any handlers. + +### Child Components + +| Name | Package | Description | +| ---------- | --------------- | -------------------------------------- | +| Box | `@mui/material` | A layout component for creating boxes. | +| Typography | `@mui/material` | A component for displaying text. | + +### Usage Example + +```jsx +import CustomTooltip from "./CustomTooltip"; + +const Chart = () => { + // Chart component logic + + return ( +
+ {/* Chart components */} + + {/* Other components */} +
+ ); +}; + +export default Chart; +``` + +In this example, the `CustomTooltip` component is imported and rendered within a `Chart` component. The `CustomTooltip` component is given the `active`, `payload`, and `label` props, which represent the tooltip's visibility, data points, and label information, respectively. The `CustomTooltip` component will be displayed when `active` is `true` and there are valid `payload` and `label` values. This component can be used within a chart component to provide customized tooltip functionality. + +## `DeviceCard` React Component + +### Description + +The `DeviceCard` component is a _React_ component that displays information about a device in a card format. The card displays basic information about the device, including its name, serial number, and a remote link. Users with the appropriate access level can edit or delete the device using the provided buttons. + +### Props + +| Name | Type | Required | Description | +| -------- | -------- | -------- | ----------------------------------------------------------------------------- | +| device | `Object` | Yes | An object containing information about the device. | +| apiary | `Object` | Yes | An object containing information about the apiary that the device belongs to. | +| userRole | `String` | Yes | A string representing the user's role in the system. | + +### State + +| Name | Type | Description | +| -------- | --------- | ------------------------------------------------------------------------------- | +| expand | `Boolean` | A boolean value indicating whether the form for editing the device is expanded. | +| formData | `Object` | An object containing the current form data for editing the device. | + +### Methods + +The `DeviceCard` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| -------- | ------------ | -------------------------------------------------------------------------- | +| onChange | `e`: `Event` | A handler function to update the `formData` state when form fields change. | +| onSubmit | `e`: `Event` | A handler function to submit the form for updating the device information. | +| onDelete | `e`: `Event` | A handler function to delete the device. | + +### Child Components + +| Name | Package | Description | +| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------- | +| Avatar | `@mui/material` | Displays a circular image or icon that represents a user or entity. | +| Box | `@mui/material` | A container component that can be used to group and space out elements. | +| Button | `@mui/material` | A component for user interaction, such as triggering an action or event. | +| Card | `@mui/material` | A container component that is used to group related content. | +| CardActions | `@mui/material` | A container component for buttons and other action elements that are placed after the main content of a card. | +| CardHeader | `@mui/material` | A container component for displaying a header in a Card. | +| Collapse | `@mui/material` | A component that enables a content to be shown or hidden based on its visibility. | +| EditOutlinedIcon | `@mui/icons-material` | An icon that represents the edit action. | +| Grid | `@mui/material` | A responsive grid container used for laying out and aligning elements in a grid system. | +| IconButton | `@mui/material` | A clickable button that contains an icon. | +| TextField | `@mui/material` | A component for getting user input from the keyboard. | +| Typography | `@mui/material` | A component for displaying text. | +| useDispatch | `react-redux` | A hook that returns a reference to the dispatch function that allows you to dispatch actions to the store. | +| useState | `react` | A hook that adds state to functional components. | +| useTheme | `@mui/material` | A hook that returns the current theme used by the application. | + +### Usage Example + +```jsx +import React from "react"; +import { DeviceCard } from "./components"; + +const MyComponent = () => { + const device = { + name: "Device 1", + serial: "123456789", + remote: "https://example.com/device1", + _id: "abc123", + }; + + const apiary = { + name: "My Apiary", + _id: "def456", + }; + + const userRole = "ADMIN"; + + return ( +
+ +
+ ); +}; +``` + +In this example, we import the `DeviceCard` component and pass in an object with `device` information, an object with `apiary` information, and the current `userRole`. The component will render the device information in a card format, and the user can click the "Edit" button to expand the form for editing the device information or the "Delete" button to delete the device. + +## `FAQCard` React Component + +### Description + +The `FAQCard` component renders a card with an FAQ (Frequently Asked Questions) question and an expandable answer section. The answer section can be toggled by clicking on an arrow icon. + +### Props + +| Name | Type | Required | Description | +| ---- | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| faq | `List` of `List` of `Strings` | Yes | An array that contains the question and the answer of the FAQ item. The first element is the question and the second element is the answer. | + +### State + +| Name | Type | Description | +| ------ | --------- | -------------------------------------------------------- | +| expand | `Boolean` | Indicates whether the answer section is expanded or not. | + +### Methods + +The `FAQCard` component does not define any methods. + +### Handlers + +The `FAQCard` component does not define any event handlers. + +### Child Components + +| Name | Package | Description | +| ----------- | --------------- | -------------------------------------------------------------- | +| Avatar | `@mui/material` | An avatar component used for displaying an icon. | +| Box | `@mui/material` | A component for grouping and organizing content. | +| Card | `@mui/material` | A card component that displays the FAQ question and answer. | +| CardActions | `@mui/material` | A component that holds the action buttons in the card header. | +| CardHeader | `@mui/material` | A component that holds the card title and avatar. | +| Collapse | `@mui/material` | A component that displays the answer section when expanded. | +| Grid | `@mui/material` | A grid component used for layout. | +| IconButton | `@mui/material` | An icon button component used for toggling the answer section. | +| Typography | `@mui/material` | A component for displaying text. | +| useTheme | `@mui/material` | A hook that provides access to the current theme object. | + +### Usage Example + +```jsx +import FAQCard from "./components/FAQCard"; + +const App = () => { + const faqList = [ + [ + "What is React?", + "React is a JavaScript library for building user interfaces.", + ], + [ + "What is JSX?", + "JSX is a syntax extension for JavaScript that allows you to write HTML-like code in your JavaScript files.", + ], + ]; + + return ( +
+ {faqList.map((faq, index) => ( + + ))} +
+ ); +}; +``` + +In this example, we pass a list of FAQs to the `FAQCard` component as a prop. The component renders a `Card` for each FAQ, displaying the question as the title and an expand/collapse button. When the button is clicked, the answer is displayed in the `Collapse` component. + +## Graph Component + +The `Graph` component displays a _Recharts_ graph with data visualization. It utilizes the _Recharts_ library for rendering the chart and includes various customization options for visualizing different data points. The component receives props to determine the data to be displayed and allows toggling the visibility of different data series. + +### Props + +| Name | Type | Required | Description | +| ----------------- | ---------- | -------- | -------------------------------------------------------------------- | +| device | `Object` | Yes | The device object containing the data to be displayed on the graph. | +| selectedFilter | `Object` | Yes | The selected filter options for the graph. | +| setSelectedFilter | `Function` | Yes | A function to update the selected filter options. | +| filterOptions | `Array` | Yes | An array of filter options to be displayed as buttons for the graph. | + +### State + +| Name | Type | Description | +| -------------- | -------- | ------------------------------------------------------------------------------------------------ | +| visible | `Object` | An object representing the visibility state of different data series on the graph. | +| displayedDates | `Array` | An array of dates that have been displayed on the X-axis of the graph. Used for date formatting. | + +### Methods + +The `Graph` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| ---------------- | -------------- | ----------------------------------------------------- | +| toggleVisibility | `e`: (`Event`) | Toggles the visibility of a data series on the graph. | + +### Child Components + +| Name | Package | Description | +| ------------- | ----------------- | ----------------------------------------------------------------- | +| CustomTooltip | `./CustomTooltip` | A custom tooltip component for displaying additional information. | + +### Usage Example + +```jsx +import { Graph } from "./components/Graph"; + +const App = () => { + const device = { + // ... device data object + }; + + const selectedFilter = { + // ... selected filter options object + }; + + const filterOptions = [ + // ... array of filter options + ]; + + const setSelectedFilter = (filter) => { + // ... function to update selected filter options + }; + + return ( +
+ {/* ... */} + + {/* ... */} +
+ ); +}; + +export default App; +``` + +In this example, the Graph component is imported and rendered within the App component. The necessary props, such as `device`, `selectedFilter`, `setSelectedFilter`, and `filterOptions`, are passed to the Graph component to determine the data and options for the graph. + +## `Loading` React Component + +### Description + +The `Loading` component displays a circular progress indicator from the Material-UI library. + +### Props + +The `Loading` component does not accept any props. + +### State + +The `Loading` component does not use any internal state. + +### Methods + +The `Loading` component does not define any methods. + +### Handlers + +The `Loading` component does not define any handlers. + +### Child Components + +The `Loading` component does not have any child components. + +### Usage Example + +```jsx +import React from "react"; +import Loading from "./Loading"; + +function MyComponent() { + return ( +
+

Loading Example

+ +
+ ); +} + +export default MyComponent; +``` + +In this example, the `Loading` component is used to display a loading spinner. + +## `Overview` ReactComponent + +The `Overview` component displays an overview of a selected device's information, such as its online status and activity within the last 24 hours. It receives the `device` prop to determine which device to display the overview for. Online status is determined by seeing if the device has sent a data point within the last 3 hours. The `Overview` component also displays the time duration that the device has been offline, if applicable. + +### Props + +| Name | Type | Required | Description | +| ------ | -------- | -------- | ---------------------------------------------------- | +| device | `Object` | Yes | The device object for which to display the overview. | + +### State + +| Name | Type | Description | +| ----------- | --------- | ----------------------------------------------------------------------------- | +| isOnline | `Boolean` | Represents whether the device is currently online. | +| offlineTime | `Number` | The time duration in seconds that the device has been offline, if applicable. | + +### Methods + +| Name | Parameters | Description | +| ---------------------- | ------------- | ----------------------------------------------------------------------------------------------- | +| getAccumulatedActivity | `device` | Retrieves the accumulated activity of the device in the last 24 hours based on its data points. | +| formatOfflineTime | `offlineTime` | Formats the offline time duration into a readable format (e.g., "2 days, 3 hours, 15 minutes"). | + +### Child Components + +| Name | Package | Description | +| ------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- | +| Typography | `@mui/material` | A component for displaying text in various styles and variants. | +| Avatar | `@mui/material` | A component for displaying images or icons representing a user or object. | +| Box | `@mui/material` | A component for creating layout containers and boxes with custom styles. | +| Card | `@mui/material` | A component for displaying cards with various content and styles. | +| CardHeader | `@mui/material` | A component for displaying headers within a card with customizable content. | +| Fade | `@mui/material` | A component for animating the entering and exiting of elements. | +| Backdrop | `@mui/material` | A component that provides a backdrop overlay for other components. | +| CheckCircleOutlineOutlinedIcon | `@mui/icons-material/CheckCircleOutlineOutlined` | An icon component for displaying a checkmark circle outline. | +| CancelOutlinedIcon | `@mui/icons-material/CancelOutlined` | An icon component for displaying a cancel symbol. | +| AddCircleOutlineOutlinedIcon | `@mui/icons-material/AddCircleOutlineOutlined` | An icon component for displaying a plus circle outline. | +| RemoveCircleOutlineOutlinedIcon | `@mui/icons-material/RemoveCircleOutlineOutlined` | An icon component for displaying a minus circle outline. | +| useSelector | `react-redux` | A React Redux hook for accessing the Redux store's state. | +| Loading | Custom component | A custom component for displaying a loading animation. | + +### Usage Example + +```jsx +import React, { useEffect, useState } from "react"; +import Overview from "./Overview"; + +const Dashboard = () => { + const [selectedDevice, setSelectedDevice] = useState(null); + + // Simulated data fetching and selection + useEffect(() => { + const fetchDevices = async () => { + // Fetch the list of devices from an API + const devices = await fetchDevicesFromAPI(); + + // Select the first device by default + if (devices.length > 0) { + setSelectedDevice(devices[0]); + } + }; + + fetchDevices(); + }, []); + + return ( +
+ {/* Render device selection UI */} + + + {/* Render Overview component */} + +
+ ); +}; + +export default Dashboard; +``` + +In this example, the `Dashboard` component fetches a list of devices from an API and renders a UI for device selection. The `Overview` component is then rendered with the selected device passed as the `device` prop. The `Overview` component displays the overview information for the selected device, including its online status and activity within the last 24 hours. + +## `SelectApiary` React Component + +The `SelectApiary` component is used for selecting an `apiary` and `device`. It receives several props and renders two select dropdowns for choosing an `apiary` and `device`. + +### Props + +| Name | Type | Required | Description | +| ----------------- | ---------- | -------- | ----------------------------------------------------- | +| apiaries | `Array` | Yes | An array of apiaries from which the user can choose. | +| apiary | `Object` | Yes | The currently selected apiary. | +| device | `Object` | Yes | The currently selected device. | +| setApiary | `Function` | Yes | A callback function for updating the selected apiary. | +| setDevice | `Function` | Yes | A callback function for updating the selected device. | +| setSelectedFilter | `Function` | Yes | A callback function for updating the selected filter. | +| selectedFilter | `Object` | Yes | The currently selected filter. | +| filterOptions | `Array` | Yes | An array of options for the filter dropdown. | + +### State + +The `SelectApiary` component does not have any internal state. + +### Methods + +| Name | Parameters | Description | +| --------- | ------------ | ---------------------------------------------------------------- | +| onChange | `e`: `Event` | A function called when the apiary select dropdown value changes. | +| onChange2 | `e`: `Event` | A function called when the device select dropdown value changes. | + +### Handlers + +The `SelectApiary` component does not define any handlers. + +### Child Components + +| Name | Package | Description | +| ----------- | --------------- | -------------------------------------------------------- | +| Box | `@mui/material` | A layout component for creating boxes. | +| useTheme | `@mui/material` | A hook for accessing the MUI theme. | +| Select | `@mui/material` | A component for selecting options from a dropdown. | +| FormControl | `@mui/material` | A component for wrapping form controls. | +| InputLabel | `@mui/material` | A component for displaying labels for form controls. | +| MenuItem | `@mui/material` | A component representing an item within a dropdown menu. | + +### Usage Example + +```jsx +import SelectApiary from "./SelectApiary"; + +const Dashboard = () => { + // Dashboard component logic + + return ( +
+ {/* Other components */} + + {/* Other components */} +
+ ); +}; + +export default Dashboard; +``` + +In this example, the `SelectApiary` component is imported and rendered within a `Dashboard` component. The `SelectApiary` component is provided with the necessary props to manage the selection of an apiary and device. This component can be used within a larger dashboard interface to allow users to select an apiary and device for further data visualization and analysis. + +## `Sidebar` React Component + +### Description + +The `Sidebar` component implements a sidebar for navigation. It uses the `react-pro-sidebar` and `@mui/material` packages. The sidebar consists of a menu with several items and submenus, and it displays information related to the user's apiaries and devices. + +### Props + +The `Sidebar` component does not accept any props. + +### State + +| Name | Type | Description | +| ----------- | --------- | ------------------------------------------------------ | +| isCollapsed | `Boolean` | Controls whether the sidebar is collapsed or expanded. | +| selected | `String` | The name of the currently selected item. | + +### Methods + +The `Sidebar` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| -------------- | ---------- | -------------------------------- | +| setIsCollapsed | `Boolean` | Updates the `isCollapsed` state. | +| setSelected | `String` | Updates the `selected` state. | + +### Child Components + +| Name | Package | Description | +| ------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `Avatar` | `@mui/material` | A component that displays an avatar image. | +| `Backdrop` | `@mui/material` | A component that provides a dark overlay behind the content to indicate that the content is disabled or not available. | +| `Box` | `@mui/material` | A layout component that can be used to create a container with a specified width, height, padding, and margin. | +| `createTheme` | `@mui/material/styles` | A function that creates a custom theme for the application. | +| `HelpOutlineOutlinedIcon` | `@mui/icons-material` | An icon component that displays a help icon. | +| `HomeOutlinedIcon` | `@mui/icons-material` | An icon component that displays a home icon. | +| `HiveOutlinedIcon` | `@mui/icons-material` | An icon component that displays a hive icon. | +| `IconButton` | `@mui/material` | A button component that displays an icon. | +| `InfoOutlinedIcon` | `@mui/icons-material` | An icon component that displays an info icon. | +| `Menu` | `react-pro-sidebar` | A component that renders a menu in the sidebar. | +| `MenuItem` | `react-pro-sidebar` | A clickable menu item in the sidebar. | +| `MenuOutlinedIcon` | `@mui/icons-material` | An icon component that displays a menu icon. | +| `ProSidebar` | `react-pro-sidebar` | A sidebar component that provides a layout for the sidebar. It can be collapsed or expanded by the user. | +| `SettingsOutlinedIcon` | `@mui/icons-material` | An icon component that displays a settings icon. | +| `SidebarContent` | `react-pro-sidebar` | A component that displays the content of the sidebar. | +| `SidebarFooter` | `react-pro-sidebar` | A component that displays a footer at the bottom of the sidebar. | +| `SidebarHeader` | `react-pro-sidebar` | A component that displays a header at the top of the sidebar. | +| `SubMenu` | `react-pro-sidebar` | A submenu that displays nested menus in the sidebar. | +| `Typography` | `@mui/material` | A component that displays text. | +| `useDispatch` | `react-redux` | A hook that returns a reference to the `dispatch` function of the Redux store. | +| `useSelector` | `react-redux` | A hook that returns a selected value from the Redux store. | +| `useTheme` | `@mui/material/styles` | A hook that returns the current theme of the application. | + +### Usage Example + +```jsx +import { Sidebar } from "./components"; + +const App = () => { + return ( +
+ +
+ ); +}; +``` + +In this example, the `Sidebar` component is imported and used within a parent component. When rendered, it will display a sidebar with several menu items and submenus. The user's apiaries and devices will be shown in the corresponding submenus, which can be clicked to navigate to the corresponding pages. The sidebar can be collapsed and expanded by clicking on the button in the top left corner. + +## `Topbar` React Component + +### Description + +The `Topbar` component is a customizable top navigation bar that can be used in a _React_ application. It includes icons for toggling between light and dark mode, viewing notifications (no implementation), accessing settings, and logging out. The component can display the title of the page or section it represents. + +### Props + +| Name | Type | Required | Description | +| ----- | -------- | -------- | ------------------------------------------- | +| title | `String` | no | The title of the page or section to display | + +### State + +The `Topbar` component does not define any state. + +### Methods + +The `Topbar` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| --------- | ------------ | ----------------------------------------------------- | +| onLogout | `e`: `Event` | Logs out the current user and redirects to login page | +| colorMode | `e`: `Event` | Toggles the color mode between light and dark mode | + +### Child Components + +| Name | Package | Description | +| ------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Avatar | `@mui/material` | A component that displays a user's profile picture or initials. | +| Box | `@mui/material` | A basic layout component that provides a flexible container for grouping and arranging other components. | +| ColorModeContext | `../theme` | A context object that provides the current color mode and a method to toggle the color mode. | +| DarkModeOutlinedIcon | `@mui/material` | An icon component that displays an outline of a light bulb for switching to dark mode. | +| IconButton | `@mui/material` | A button component that displays an icon. | +| LightModeOutlinedIcon | `@mui/material` | An icon component that displays an outline of a light bulb for switching to light mode. | +| logout | `../features/auth/auth.slice.js` | An action creator that dispatches an action to log out the user. | +| LogoutOutlinedIcon | `@mui/material` | An icon component that displays a door with an arrow icon for logging out. | +| NotificationsOutlinedIcon | `@mui/material` | An icon component that displays a bell icon for showing notifications. | +| reset | `../features/auth/auth.slice.js` | An action creator that dispatches an action to reset the auth state. | +| useContext | `react` | A hook that provides access to a context object. | +| useDispatch | `react-redux` | A hook that returns a reference to the dispatch function from the Redux store. | +| useSelector | `react-redux` | A hook that returns selected parts of the state from the Redux store. | +| SettingsOutlinedIcon | `@mui/material` | An icon component that displays a gear icon for opening the settings. | +| Typography | `@mui/material` | A component that renders text in various styles and sizes. | +| useNavigate | `react-router-dom` | A hook that returns a navigate function for programmatic navigation. | +| useTheme | `@mui/material` | A hook that returns the current theme object. | + +### Usage + +```jsx +import Topbar from "./Topbar"; + +function MyPage() { + return ( +
+ +

This is the content of the page.

+
+ ); +} +``` + +In this example, the `Topbar` component is displayed at the top of the `MyPage` component with the title "My Page Title". The `p` tag below it represents the rest of the page content. The `Topbar` component can be customized with _CSS_ styling or _MUI_ theming. + +## `UserCard` React Component + +### Description + +The `UserCard` component is a card component that displays user information and allows for editing user roles and deleting users. + +### Props + +| Name | Type | Required | Description | +| ------ | -------- | -------- | -------------------------------------- | +| user | `Object` | Yes | The user object to be displayed. | +| apiary | `Object` | Yes | The apiary object the user belongs to. | + +### State + +| Name | Type | Description | +| -------- | --------- | ----------------------------------------------------------------------- | +| expand | `Boolean` | A state variable that determines if the card should be expanded or not. | +| formData | `Object` | A state variable that holds the user's data to be edited. | + +### Methods + +The `UserCard` component does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| -------- | ------------ | ------------------------------------------------------------------- | +| onChange | `e`: `Event` | A function that handles form data changes. | +| onSubmit | `e`: `Event` | A function that handles the form submission for updating user data. | +| onDelete | `e`: `Event` | A function that handles the form submission for deleting a user. | + +### Child Components + +| Name | Package | Description | +| ---------------- | --------------- | --------------------------------------------------------- | +| Avatar | `@mui/material` | Displays the user's avatar. | +| Box | `@mui/material` | Wraps the forms and aligns the editing menu. | +| Button | `@mui/material` | Submits the form for saving changes or deleting the user. | +| Card | `@mui/material` | Wraps the user's information. | +| CardActions | `@mui/material` | Displays the edit icon to open the editing menu. | +| CardHeader | `@mui/material` | Displays the user's name, email, and avatar. | +| Checkbox | `@mui/material` | Allows for toggling between user roles. | +| Collapse | `@mui/material` | Collapses the editing menu. | +| FormControlLabel | `@mui/material` | Displays a label for the Checkbox. | +| Grid | `@mui/material` | Wraps the edit icon for proper alignment. | +| IconButton | `@mui/material` | The button component for the edit icon. | +| Typography | `@mui/material` | Displays the user's name and email. | + +### Usage Example + +```jsx +import UserCard from "./components/UserCard"; + +const MyComponent = () => { + const apiary = { + _id: "1", + name: "My Apiary", + members: [ + { + user: { + _id: "2", + name: "John Doe", + email: "johndoe@example.com", + }, + role: "ADMIN", + }, + { + user: { + _id: "3", + name: "Jane Smith", + email: "janesmith@example.com", + }, + role: "USER", + }, + ], + }; + + return ( +
+ + +
+ ); +}; +``` + +In this example, the `UserCard` component is used to display user information for two different members of the same apiary. The component is passed the user object and apiary object as props. When the edit icon is clicked, the editing menu is displayed, allowing the user's role to be changed or the user to be deleted. + +# Features + +## Apiary + +### `apiary.slice` + +#### Description + +This _Redux_ slice file manages the state related to Apiaries. The file contains a set of initial states, action creators and their respective reducer cases to handle the apiaries _CRUD_ operations. The file also integrates with the `apiaryService` module to perform the actual API calls. + +#### Initial States + +| Name | Type | Description | +| --------- | --------- | ----------------------------------------------------------------------------- | +| apiaries | `Array` | A list of objects representing the user's apiaries. | +| isError | `Boolean` | Indicates if an error occurred while processing a request. | +| isSuccess | `Boolean` | Indicates if a request was processed successfully. | +| isLoading | `Boolean` | Indicates if a request is in progress. | +| message | `String` | A message that provides additional information about the status of a request. | + +#### Actions + +| Name | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| getApiaries | This action creator dispatches an API call to retrieve a list of user apiaries without populating the devices' data | +| getApiariesWithDeviceData | This action creator dispatches an API call to retrieve a list of user apiaries with populating the devices' data | +| setApiary | This action creator dispatches an API call to create a new apiary for the user. | +| updateApiary | This action creator dispatches an API call to update an existing apiary. | +| deleteApiary | This action creator dispatches an API call to delete an existing apiary. | +| setDevice | This action creator dispatches an API call to create a new device for an apiary. | +| updateDevice | This action creator dispatches an API call to update an existing device of an apiary. | +| deleteDevice | This action creator dispatches an API call to delete an existing device of an apiary. | +| setMember | This action creator dispatches an API call to create a new member for an apiary. | +| updateMember | This action creator dispatches an API call to update an existing member of an apiary. | + +#### Reducer Cases + +| Case | apiaries | isError | isSuccess | isLoading | message | +| ----------------------------------- | ------------------------- | ------- | --------- | --------- | ----------------------------- | +| getApiaries.pending | - | `false` | `false` | `true` | "Loading" | +| getApiaries.fulfilled | Action payload (apiaries) | `false` | `true` | `false` | "Loaded successfully" | +| getApiaries.rejected | - | `true` | `false` | `false` | "Error while loading" | +| getApiariesWithDeviceData.pending | - | `false` | `false` | `true` | "Loading" | +| getApiariesWithDeviceData.fulfilled | Action payload (apiaries) | `false` | `true` | `false` | "Loaded successfully" | +| getApiariesWithDeviceData.rejected | - | `true` | `false` | `false` | "Error while loading" | +| setApiary.pending | - | `false` | `false` | `true` | "Creating new apiary" | +| setApiary.fulfilled | - | `false` | `true` | `false` | "Apiary created successfully" | +| setApiary.rejected | - | `true` | `false` | `false` | "Error while creating apiary" | +| updateApiary.pending | - | `false` | `false` | `true` | "Updating apiary" | +| updateApiary.fulfilled | - | `false` | `true` | `false` | "Apiary updated successfully" | +| updateApiary.rejected | - | `true` | `false` | `false` | "Error while updating apiary" | +| deleteApiary.pending | - | `false` | `false` | `true` | "Deleting apiary" | +| deleteApiary.fulfilled | - | `false` | `true` | `false` | "Apiary deleted successfully" | +| deleteApiary.rejected | - | `true` | `false` | `false` | "Error while deleting apiary" | +| setDevice.pending | - | `false` | `false` | `true` | "Creating new device" | +| setDevice.fulfilled | - | `false` | `true` | `false` | "Device created successfully" | +| setDevice.rejected | - | `true` | `false` | `false` | "Error while creating device" | +| updateDevice.pending | - | `false` | `false` | `true` | "Updating device" | +| updateDevice.fulfilled | - | `false` | `true` | `false` | "Device updated successfully" | +| updateDevice.rejected | - | `true` | `false` | `false` | "Error while updating device" | +| deleteDevice.pending | - | `false` | `false` | `true` | "Deleting device" | +| deleteDevice.fulfilled | - | `false` | `true` | `false` | "Device deleted successfully" | +| deleteDevice.rejected | - | `true` | `false` | `false` | "Error while deleting device" | + +### `apiary.service` + +This _Redux_ service file contains a set of functions that perform the actual API calls to the backend. The file also contains a set of functions that handle the response of the API calls and dispatch the appropriate actions to update the state of the `apiary.slice` file. + +#### Functions + +| Function | Parameters | Description | +| ------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------- | +| getApiaries | `token` | Retrieves the list of Apiaries (without populating the devices' data) associated with the authenticated user's token. | +| getApiariesWithDeviceData | `data`, `token` | Retrieves the list of Apiaries (with populating the devices' data) associated with the authenticated user's token. | +| setApiary | `apiaryData`, `token` | Creates a new Apiary with the given data and token. | +| updateApiary | `apiaryData`, `token` | Updates an existing Apiary with the given data and token. | +| deleteApiary | `apiaryData`, `token` | Deletes an Apiary with the given data and token. | +| setDevice | `apiaryData`, `token` | Sets a new device for an existing Apiary with the given data and token. | +| updateDevice | `apiaryData`, `token` | Updates an existing device for an existing Apiary with the given data and token. | +| deleteDevice | `apiaryData`, `token` | Deletes an existing device for an existing Apiary with the given data and token. | +| setMember | `userData`, `token` | Sets a new member for an existing Apiary with the given data and token. | +| updateMember | `userData`, `token` | Updates an existing member for an existing Apiary with the given data and token. | +| deleteMember | `userData`, `token` | Deletes an existing member for an existing Apiary with the given data and token. | + +## Auth + +### `auth.slice` + +#### Description + +This _Redux_ slice file defines the `auth` slice for managing authentication-related state. It uses the `createSlice` and `createAsyncThunk` functions from the `@reduxjs/toolkit` package to define the initial state, actions, and reducer cases. + +#### Initial State + +| Name | Type | Description | +| --------- | ------------------ | ----------------------------------------------------------------------- | +| user | `Object` or `Null` | The currently logged in user, retrieved from local storage. | +| isError | `Boolean` | Indicates whether an error occurred during an async operation. | +| isSuccess | `Boolean` | Indicates whether an async operation was successful. | +| isLoading | `Boolean` | Indicates whether an async operation is currently in progress. | +| message | `String` | An error message or success message associated with an async operation. | + +#### Actions + +| Name | Description | +| -------- | -------------------------------------------- | +| register | Async action that registers a user. | +| login | Async action that logs in a user. | +| logout | Async action that logs out the current user. | +| reset | Action that resets the authentication state. | + +#### Reducer Cases + +| Case | User | Error | Success | Loading | Message | +| ------------------ | --------------------- | ------------------------------ | ------- | ------- | ------------------------------ | +| register.pending | - | - | - | `true` | - | +| register.fulfilled | Action payload (user) | - | `true` | `false` | - | +| register.rejected | - | Action payload (error message) | - | `false` | Action payload (error message) | +| login.pending | - | - | - | `true` | - | +| login.fulfilled | Action payload (user) | - | `true` | `false` | - | +| login.rejected | - | Action payload (error message) | - | `false` | Action payload (error message) | +| logout.fulfilled | - | - | - | - | - | + +Note: In the reducer cases table, the `Action payload` column refers to the value returned by the corresponding async action. + +### `auth.service` + +#### Description + +This _Redux_ service file contains a set of functions that perform the actual API calls to the backend. The file also contains a set of functions that handle the response of the API calls and dispatch the appropriate actions to update the state of the `auth.slice` file. + +#### Functions + +| Function | Parameters | Description | +| -------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| register | `userData` | Registers a new user with the provided user data. The `userData` parameter should be an object that includes the user's email, username, and password. If the registration is successful, the user's information will be saved to local storage. Returns the response data from the server. | +| login | `userData` | Authenticates a user with the provided user data. The `userData` parameter should be an object that includes the user's email and password. If the user has selected the "Remember Me" checkbox, their information will be saved to local storage. Returns the response data from the server. | +| logout | - | Removes the user's information from local storage, effectively logging them out. | + +# Pages + +## `About` React Page + +### Description + +The `About` Page displays information about the project and the team behind it. + +### Props + +The `About` component does not receive any props. + +### State + +The `About` component does not have any state. + +### Methods + +The `About` component does not define any methods. + +### Handlers + +The `About` component does not define any handlers. + +### Child Components + +| Component Name | Package | Description | +| ---------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| AboutCard | `../components/AboutCard` | A custom component that displays a statement and its description in a card format. | +| Avatar | `@mui/material` | A circular component that represents a user or object. It can display an image, icon, or text. | +| Backdrop | `@mui/material` | A component that provides a dark overlay behind other components. It is often used to indicate that a process is running in the background. | +| Box | `@mui/material` | A container component that can contain other MUI components or HTML elements. It provides various layout options such as flexbox and grid. | +| Fade | `@mui/material` | A transition component that animates the appearance of an element by gradually fading it in or out. | +| Grid | `@mui/material` | A responsive grid container that can hold other MUI components or HTML elements. It allows for customization of column and row spacing for different screen sizes. | +| Grow | `@mui/material` | A transition component that animates the appearance of an element by gradually increasing its size. | +| InfoOutlinedIcon | `@mui/material` | An MUI icon component that displays an "info" icon. | +| tokens | `../theme` | An object that provides color values based on the current theme. It is used to customize the styling of non-MUI components. | +| Typography | `@mui/material` | A component for displaying text. It supports various typography styles such as headings, body text, and captions. | +| useDispatch | `react-redux` | A hook that provides access to the dispatch function of the Redux store. It can be used to dispatch actions to update the state. | +| useEffect | `react` | A hook that runs a side effect after rendering. It is used to perform actions such as updating the state or making API requests. | +| useNavigate | `react-router-dom` | A hook that provides access to the navigation object. It can be used to navigate to different pages in the application. | +| useSelector | `react-redux` | A hook that provides access to the state of the Redux store. It can be used to retrieve data from the store. | +| useTheme | `@mui/material` | A hook that provides access to the current theme object. It can be used to customize the styling of MUI components. | + +### Usage Example + +```jsx +import About from "../pages/About"; + +const App = () => { + return ( +
+ +
+ ); +}; +``` + +In this example, the renders the `About` component which displays information about the project and the team behind it. + +## `Dashboard` React Page + +The `Dashboard` component represents a dashboard page that displays various data visualizations and overviews. It utilizes several child components and props to provide a comprehensive view of the user's data for each of their devices. Using the `SelectApiary` component, the user can select an `apiary` and a corresponding `device` to then populate the dashboard (Recharts graph and overview) with the data from that device. The `Dashboard` component also provides a filtering component that allows the user to filter the data by date range, as well as select which metrics to populate the graph with. + +### Props + +The `Dashboard` component does not receive any props. + +## State + +| Name | Type | Description | +| -------------- | -------- | ---------------------------------- | +| apiary | `String` | Stores the selected apiary. | +| device | `String` | Stores the selected device. | +| ovDevice | `String` | Stores the overview device. | +| selectedFilter | `Object` | Stores the selected filter option. | + +## Methods + +The `Dashboard` component does not define any methods. + +## Handlers + +The `Dashboard` component does not define any handlers. + +## Child Components + +| Name | Package | Description | +| ------------------------- | --------------------------------- | ----------------------------------------------------------- | +| Box | `@mui/material` | A layout component for creating boxes. | +| Typography | `@mui/material` | A component for displaying text. | +| useTheme | `@mui/material` | A hook for accessing the theme object. | +| Grid | `@mui/material` | A component for creating a responsive grid layout. | +| useDispatch | `react-redux` | A hook for accessing the Redux dispatch function. | +| useSelector | `react-redux` | A hook for accessing the Redux store state. | +| useState | `react` | A hook for managing component state. | +| useEffect | `react` | A hook for handling side effects in functional components. | +| useNavigate | `react-router-dom` | A hook for accessing the navigation object in React Router. | +| getApiariesWithDeviceData | `../features/apiary/apiary.slice` | A Redux action for fetching apiaries with device data. | +| reset | `../features/apiary/apiary.slice` | A Redux action for resetting apiary data. | +| toast | `react-toastify` | A function for displaying toast notifications. | +| Graph | `../components/Graph` | A custom graph component. | +| Overview | `../components/Overview` | A custom overview component. | +| SelectApiary | `../components/SelectApiary` | A component for selecting an apiary. | + +## Usage Example + +```jsx +import Dashboard from "./Dashboard"; + +const App = () => { + return ( +
+ {/* Other components */} + + {/* Other components */} +
+ ); +}; + +export default App; +``` + +In this example bove, the `Dashboard` component is imported and rendered within the `App` component. This allows the `Dashboard` to be displayed as a part of the larger application. Other components can be added before and after the `Dashboard` to create a complete user interface. + +## `FAQ` React Page + +### Description + +The `FAQ` page renders a frequently asked questions page. It displays a list of FAQ cards, each with a questions and their corresponding answer. + +### Props + +The `FAQ` page does not receive any props. + +### State + +The `FAQ` page does not have any state. + +### Methods + +The `FAQ` page does not define any methods. + +### Handlers + +The `FAQ` page does not define any handlers. + +### Child Components + +| Name | Package | Description | +| ----------------------- | ----------------------- | -------------------------------------------------------------------------- | +| Box | `@mui/material` | A container component used for grouping and spacing elements | +| Grid | `@mui/material` | A responsive layout component used for aligning and distributing elements | +| Typography | `@mui/material` | A component for displaying text with customizable typography | +| Avatar | `@mui/material` | A component for displaying user avatars or icons | +| useTheme | `@mui/material` | A hook that provides access to the MUI theme object | +| HiveOutlinedIcon | `@mui/icons-material` | An icon component for displaying a hive outline | +| tokens | `../theme` | A module that exports a function returning theme tokens | +| Loading | `../components/Loading` | A component for displaying a loading animation | +| useDispatch | `react-redux` | A hook that returns the Redux store's dispatch method | +| useSelector | `react-redux` | A hook that returns selected state from the Redux store | +| useEffect | `react` | A hook that allows performing side effects in function components | +| useNavigate | `react-router-dom` | A hook that returns a navigate function to use for programmatic navigation | +| HelpOutlineOutlinedIcon | `@mui/icons-material` | An icon component for displaying a help outline | +| FAQCard | `../components/FAQCard` | A component that displays a question and answer card | +| toast | `react-toastify` | A module for displaying toast notifications | +| Grow | `@mui/material` | A component for animating element growth | +| Fade | `@mui/material` | A component for animating element opacity | +| Backdrop | `@mui/material` | A component for displaying a translucent background overlay | + +## `Login` React Page + +### Description + +The `Login` page allows users to log into an application. The component receives user inputs of `email`, `password`, and `isChecked`. It dispatches a login action with the input data to authenticate users via the `auth.slice.js` module. It also displays a loading spinner while the authentication is in progress. If the authentication fails, it displays an error message. The user is prompted to enter their email and password. The user can also select the `Remember me` checkbox to save their login credentials. + +### Props + +The `Login` component does not accept any props. + +### State + +| Name | Type | Description | +| ---------- | --------- | --------------------------------------------------------------------------------------------------- | +| `trans` | `Boolean` | A state variable that determines whether to transition to another view. | +| `formData` | `Object` | A state variable that stores the user's input data, including `email`, `password`, and `isChecked`. | + +### Methods + +The `Login` page does not define any methods. + +### Handlers + +| Name | Parameters | Description | +| ----------- | ------------ | ---------------------------------------------------------------------------------------------------- | +| handleTrans | - | A function that toggles the `trans` state to transition to another view. | +| onChange | `e`: `Event` | A function that updates the `formData` state with the new user input data. | +| onSubmit | `e`: `Event` | A function that dispatches the login action with the `formData` state data to authenticate the user. | + +### Child Components + +| Name | Package | Description | +| ------- | --------------- | ---------------------------------------------------------------- | +| Loading | `../components` | A spinner that displays while the authentication is in progress. | + +### Usage Example + +```jsx +import React from "react"; +import Login from "./Login"; + +const App = () => { + return ; +}; + +export default App; +``` + +In this example, we import the `Login` component and render it in the `App` component. This will display the login form to the user. + +## `Manage` React Page + +### Description + +The `Manage` page allows users to manage their apiaries. It displays a list of existing apiaries that the user has access to, and allows the user to create new apiaries, edit existing apiaries, and delete apiaries. Within each apiary card, the user can view the apiary's devices/members, add new devices/members, edit existing devices/members, and delete devices/members. + +### Props + +The `Manage` component does not receive any props. + +### State + +The `Manage` component does not have any state. + +### Methods + +The `Manage` component does not define any methods. + +### Handlers + +The `Manage` component does not define any handlers. + +### Child Components + +| Name | Package | Description | +| ------------- | ----------------------------- | ----------------------------------------------------------------------- | +| Loading | `../components/Loading` | Displays a loading spinner while data is being fetched from the server. | +| UserCard | `../components/UserCard` | Displays information about the user. | +| DeviceCard | `../components/DeviceCard` | Displays information about a device. | +| ApiaryCard | `../components/ApiaryCard` | Displays information about an apiary. | +| AddApiaryCard | `../components/AddApiaryCard` | Displays a form for adding a new apiary. | + +### Usage + +```jsx +import Manage from "./Manage"; + +function MyComponent() { + return ( +
+ +
+ ); +} +``` + +In this example, the `Manage` component will display a list of existing apiaries, along with a form for adding a new apiary. If the user is not logged in, they will be redirected to the login page. If there is an error fetching data from the server, an error message will be displayed. If data is being fetched from the server, a loading spinner will be displayed. + +## `Register` React Page + +### Description + +The `Register` component is a form that allows users to register for a new account. This component is built using React and various components from the Material UI library. It relies on the Redux store to handle user authentication and makes use of React Router DOM to handle navigation. The user is prompted to enter their name, email, password, and confirm password.The user can then submit the form to register for a new account. If the registration is successful, the user is redirected to the dashboard. If the registration fails, an error message is displayed. + +### Props + +The `Register` component does not accept any props. + +### State + +| Name | Type | Description | +| -------- | --------- | ------------------------------------------------------------------------------- | +| formData | `Object` | An object containing the form data for registration | +| trans | `Boolean` | A boolean value indicating whether the component should be displayed or hidden. | + +### Methods + +| Name | Parameters | Description | +| -------- | ------------ | --------------------------------------------------------------------- | +| onChange | `e`: `Event` | A callback function that updates the state with the new form data. | +| onSubmit | `e`: `Event` | A callback function that submits the registration form to the server. | + +### Handlers + +The `Register` component does not define any handlers. + +### Child Components + +| Name | Package | Description | +| ---------------- | --------------------- | ------------------------------------------------------------------------------------ | +| Avatar | `@mui/material` | A Material UI component used to display an avatar icon. | +| Backdrop | `@mui/material` | A Material UI component used to create a backdrop. | +| Box | `@mui/material` | A Material UI component used to create a layout container. | +| Button | `@mui/material` | A Material UI component used to render the "Sign Up" button. | +| Container | `@mui/material` | A Material UI component used to create a responsive container. | +| CssBaseline | `@mui/material` | A Material UI component used to reset the CSS styles to a consistent baseline. | +| Fade | `@mui/material` | A Material UI component used to create a fade transition. | +| FilledInput | `@mui/material` | A Material UI component used to create an input with filled background. | +| Grid | `@mui/material` | A Material UI component used to create a responsive grid. | +| Grow | `@mui/material` | A Material UI component used to create a grow transition. | +| Link | `@mui/material` | A Material UI component used to render a link to the login page. | +| LockOutlinedIcon | `@mui/icons-material` | A Material UI component used to display a lock icon. | +| Loading | `../components` | A component that displays a loading spinner when the registration form is submitted. | +| TextField | `@mui/material` | A Material UI component used to display and handle text input. | +| ThemeProvider | `@mui/material` | A Material UI component used to provide a theme to the application. | +| toast | `react-toastify` | A third-party library used to display toast notifications. | +| Typography | `@mui/material` | A Material UI component used to display text. | + +### Usage + +```jsx +import React from "react"; +import Register from "./components/Register"; + +const App = () => { + return ; +}; + +export default App; +``` + +In this example, the `Register` component is imported and rendered in the main `App` component. When the user navigates to the "/register" route, the `Register` component will be displayed, allowing them to create a new account. + +# Deployment + +## Prerequisites + +- Heroku CLI + +## Instructions + +1. Install the Heroku CLI +2. Login to Heroku +3. Create a new Heroku app +4. Set up the environment variables in the Heroku app +5. Add the Heroku remote to the local git repository +6. Push the local git repository to the Heroku remote + +## Troubleshooting + +If you encounter errors when deploying to Heroku, try running the following: + +1. Clear the Heroku build cache + +```bash +$ heroku config:set NODE_MODULES_CACHE=false +``` + +2. Ensure the `scripts` section of the `package.json` file contains the following: (Note: the `--legacy-peer-deps` flag is required for Heroku to install the dependencies correctly) + +```json +"scripts": { + "start": "node backend/server.js", + "server": "nodemon backend/server.js", + "client": "npm start --prefix frontend", + "dev": "concurrently \"npm run server\" \"npm run client\"", + "frontend": "npm install --prefix frontend", + "yayaya": "concurrently \"npm install\" \"npm run frontend\"", + "preinstall": "npx npm-force-resolutions", + "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix frontend --legacy-peer-deps && npm run build --prefix frontend --legacy-peer-deps" + }, +``` + +# Testing + +Testing has been manual. Consider implementing a testing framework using Jest and Enzyme. + +# License + +Property of Santa Clara University under SCU's Senior Design Program + +# Acknowledgments + +Special thanks to Kian Nikzad, Wendy Mather, and Gerhard and Lisa Eschelbeck for their continued support on this project's creative direction and design decisions. + +# Appendix + +Include any additional information or resources, such as troubleshooting tips or frequently asked questions. + +# Screenshots + +## Sign Up Page + +![Sign Up](/frontend/public/signup-ss.PNG) + +## Sign In Page + +![Sign In](/frontend/public/signin-ss.PNG) + +## Dashboard Page + +![Dashboard](/frontend/public/db-ss.PNG) + +## Manage Page + +![Manage](/frontend/public/manage-ss.PNG) + +## About Page + +![About](/frontend/public/about-ss.PNG) + +## FAQ Page + +![FAQ](/frontend/public/faq-ss.PNG) + +# Video Demo + +[![Video Demo on YouTube](https://img.youtube.com/vi/zT9ShMXCmIA/0.jpg)](https://www.youtube.com/watch?v=zT9ShMXCmIA) + +# Contact + +- Any questions regarding the web application, please contact **cpaiz@scu.edu** or **paizcollin@gmail.com** +- Any questions regarding deployment, please contact **cpaiz@scu.edu** or **paizcollin@gmail.com** or **jstock@scu.edu** +- Any questions regarding hardware, please contact **ewrysinski@scu.edu** and **dblanc@scu.edu** diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 0000000..9214709 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,15 @@ +const mongoose = require("mongoose"); + +const connectDB = async () => { + try { + + const conn = await mongoose.connect(process.env.ATLAS_URI); + + console.log(`MongoDB Connected: ${conn.connection.host}`.cyan.underline); + } catch (error) { + console.log(error); + process.exit(1); + } +}; + +module.exports = connectDB; diff --git a/backend/controllers/apiary.controller.js b/backend/controllers/apiary.controller.js new file mode 100644 index 0000000..8657e42 --- /dev/null +++ b/backend/controllers/apiary.controller.js @@ -0,0 +1,594 @@ +const asyncHandler = require("express-async-handler"); +const Apiary = require("../models/apiary.model.js"); +const User = require("../models/user.model"); +const Data = require("../models/data.model"); +const mongoose = require("mongoose"); + +// @status WORKING +// @desc Check that user is part of apiary +// @return Returns a user_id, role, and apiary +async function checkUserToApiary(req, res) { + // Find apiary from param :apiary_id + const apiary = await Apiary.findById(req.params.apiary_id); + + console.log(apiary); + + // If apiary not found, error + if (!apiary) { + res.status(400); + throw new Error("Apiary not found"); + } + + // Check for user (logged in essentially, from protect) + if (!req.user) { + res.status(400); + throw new Error("User not found"); + } + + // Make sure the logged in user matches a member of this apiary and is an admin + var user; + var role; + apiary.members.forEach((member) => { + if (member.user.toString() === req.user.id) { + user = member.user; + role = member.role; + return; + } + }); + + return { user, role, apiary }; +} + +// @status WORKING +// @desc Get apiaries (no device data) +// @route GET /api/apiaries +// @access Private; all users +const getApiaries = asyncHandler(async (req, res) => { + // Find apiaries where current user (from protect) is a member + const apiaries = await Apiary.find({ + members: { + $elemMatch: { + user: req.user.id, + }, + }, + }).populate("members.user"); + + res.status(200).json(apiaries); +}); + +// @status WORKING +// @desc Get apiaries (with device data) +// @route GET /api/apiaries/filter/:filter +// @access Private; all users +const getApiaryWithDeviceData = asyncHandler(async (req, res) => { + const filter = req.params.filter; // Assuming the filter parameter is passed as a query parameter + + // console.log(filter); + + let fromDate; + switch (filter) { + case "init": + fromDate = new Date(Date.now()); // Unix epoch (oldest date) + break; + case "1day": + fromDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // One day ago + break; + case "1week": + fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // One week ago + break; + case "1month": + fromDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // One month ago + break; + case "3month": + fromDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Three months ago + break; + case "6month": + fromDate = new Date(Date.now() - 180 * 24 * 60 * 60 * 1000); // Six months ago + break; + case "1year": + fromDate = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // One year ago + break; + case "2year": + fromDate = new Date(Date.now() - 730 * 24 * 60 * 60 * 1000); // Two years ago + break; + default: + // No filter or invalid filter, fetch all-time data + fromDate = new Date(0); // Unix epoch (oldest date) + break; + } + + const apiaries = await Apiary.find({ + members: { $elemMatch: { user: req.user.id } }, + }).populate("members.user"); + + const updatedApiaries = await Promise.all( + apiaries.map(async (apiary) => { + const devices = apiary.devices; + + const updatedDevices = await Promise.all( + devices.map(async (device) => { + const data = await Data.aggregate([ + { $match: { _id: device.data } }, + { + $project: { + _id: 0, + datapoints: { + $filter: { + input: "$datapoints", + as: "point", + cond: { $gte: ["$$point.time", fromDate] }, + }, + }, + }, + }, + { + $addFields: { + dataSize: { $size: "$datapoints" }, + interval: { + $max: [ + 1, + { $ceil: { $divide: [{ $size: "$datapoints" }, 1000] } }, + ], + }, + }, + }, + { + $project: { + selectedData: { + $reduce: { + input: { $range: [0, "$dataSize", "$interval"] }, + initialValue: [], + in: { + $concatArrays: [ + "$$value", + [ + { + $arrayElemAt: ["$datapoints", "$$this"], + }, + ], + ], + }, + }, + }, + }, + }, + ]); + + return { + _id: device._id, + serial: device.serial, + name: device.name, + remote: device.remote, + data: { + datapoints: data.length > 0 ? data[0].selectedData : [], + }, + }; + }) + ); + + return { + _id: apiary._id, + name: apiary.name, + location: apiary.location, + members: apiary.members, + devices: updatedDevices, + }; + }) + ); + + res.status(200).json(updatedApiaries); +}); + +// @status WORKING +// @desc Set apiary +// @route POST /api/apiaries +// @access Private; all users +const setApiary = asyncHandler(async (req, res) => { + // Get info from body + const { name, location } = req.body; + + // If missing any req fields, error + if (!name || !location) { + res.status(400); + throw new Error("Please include all required fields"); + } + + // Check if apiary exists + const apiaryExists = await Apiary.findOne({ name }); + + // If apiaryExists, error + if (apiaryExists) { + res.status(400); + throw new Error("Apiary already exists"); + } + + // Create apiary + // .create won't work + // find by an empty id (won't find one), so instead of updating, it "upserts" (creates) a new one + const apiary = await Apiary.findOneAndUpdate( + { _id: mongoose.Types.ObjectId() }, + { + name: name, + location: location, + members: { + user: req.user.id, + role: "CREATOR", + }, + devices: [], + }, + { + new: true, + upsert: true, + } + ).populate("members.user"); + + res.status(200).json(apiary); +}); + +// @status WORKING +// @desc Update apiary +// @route PUT /api/apiaries/:apiary_id +// @access Private; admins of apiary only +const updateApiary = asyncHandler(async (req, res) => { + const { user, role } = await checkUserToApiary(req, res); + + // If not an admin or not the currently logged in user, unauthorized + if ( + (role != "CREATOR" && role != "ADMIN") || + user.toString() !== req.user.id + ) { + res.status(400); + throw new Error( + "User not authorized. User must be an admin of the apiary to update it" + ); + } + + // Update the apiary accordingly (only update name, location) + // Cannot update arrays directly (prevent overwriting array) + const updatedApiary = await Apiary.findByIdAndUpdate( + { _id: req.params.apiary_id }, + { + $set: { + name: req.body.name, + //location: req.body.location, + }, + }, + { + new: true, + } + ).populate("members.user"); + + res.status(200).json(updatedApiary); +}); + +// @status WORKING +// @desc Delete apiary +// @route DELETE /api/apiaries/:apiary_id +// @access Private; creator of apiary only +const deleteApiary = asyncHandler(async (req, res) => { + const { user, role, apiary } = await checkUserToApiary(req, res); + + // If not the creator or not the currently logged in user, unauthorized + if (role != "CREATOR" || user.toString() !== req.user.id) { + res.status(400); + throw new Error( + "User not authorized. User must be the original creator of the apiary to delete it" + ); + } + + // Delete all data associated with apiary + await Data.deleteMany({ apiary: apiary._id }); + + // Delete apiary + await apiary.remove(); + + res.status(200).json({ _id: req.params.apiary_id }); +}); + +// @status WORKING +// @desc Set device +// @route PUT /api/apiaries/device/:apiary_id +// @access Private; admins of apiary only +const setDevice = asyncHandler(async (req, res) => { + const { user, role } = await checkUserToApiary(req, res); + const { serial, name, remote } = req.body; + + // If not an admin or not the currently logged in user, unauthorized + if ( + (role != "CREATOR" && role != "ADMIN") || + user.toString() !== req.user.id + ) { + res.status(400); + throw new Error( + "User not authorized. User must be an admin of the apiary to update it" + ); + } + + // Check if device exists + const deviceExists = await Apiary.findOne({ + devices: { + $elemMatch: { + serial: req.body.serial, + }, + }, + }); + + // If deviceExists, error + if (deviceExists) { + res.status(400); + throw new Error("Device already exists"); + } + + const newData = await Data.create({ + serial: serial, + apiary: req.params.apiary_id, + data: {}, + }); + + const updatedApiary = await Apiary.findByIdAndUpdate( + { _id: req.params.apiary_id }, + { + $push: { + devices: { + serial: serial, + name: name, + remote: remote, + data: newData._id, + }, + }, + }, + { + new: true, + } + ).populate("members.user"); + + res.status(200).json(updatedApiary); +}); + +// @status WORKING +// @desc Update device +// @route PUT /api/apiaries/device/:apiary_id&:device_id +// @access Private; admins of apiary only +const updateDevice = asyncHandler(async (req, res) => { + const { user, role } = await checkUserToApiary(req, res); + const { serial, name, remote } = req.body; + + // If not an admin or not the currently logged in user, unauthorized + if ( + (role != "CREATOR" && role != "ADMIN") || + user.toString() !== req.user.id + ) { + res.status(400); + throw new Error( + "User not authorized. User must be an admin of the apiary to update it" + ); + } + + const updatedApiary = await Apiary.findOneAndUpdate( + { _id: req.params.apiary_id, "devices._id": req.params.device_id }, + { + $set: { + "devices.$.name": name, + "devices.$.remote": remote, + }, + }, + { + new: true, + } + ).populate("members.user"); + + if (!updatedApiary) { + res.status(400); + throw new Error("Device was not found"); + } + + res.status(200).json(updatedApiary); +}); + +// @status WORKING +// @desc Delete device +// @route DELETE /api/apiaries/device/:apiary_id&:device_id +// @access Private; admins of apiary only +const deleteDevice = asyncHandler(async (req, res) => { + const { user, role } = await checkUserToApiary(req, res); + + // If not an admin or not the currently logged in user, unauthorized + if ( + (role != "CREATOR" && role != "ADMIN") || + user.toString() !== req.user.id + ) { + res.status(400); + throw new Error( + "User not authorized. User must be an admin of the apiary to update it" + ); + } + + const updatedApiary = await Apiary.findByIdAndUpdate( + { _id: req.params.apiary_id }, + { + $pull: { + devices: { + _id: req.params.device_id, + }, + }, + }, + { + new: true, + } + ).populate("members.user"); + + if (!updatedApiary) { + res.status(400); + throw new Error("Device was not found"); + } + + const deletedData = await Data.findOneAndDelete({ + serial: req.params.serial, + }); + + res.status(200).json(updatedApiary); +}); + +// @status WORKING +// @desc Update members to apiary +// @route PUT /api/apiaries/member/:apiary_id&:user_id&setEditor +// @access Private; admins of apiary only +const setMember = asyncHandler(async (req, res) => { + const { user, role, apiary } = await checkUserToApiary(req, res); + + // If not an admin or not the currently logged in user, unauthorized + if ( + (role != "CREATOR" && role != "ADMIN") || + user.toString() !== req.user.id + ) { + res.status(400); + throw new Error( + "User not authorized. User must be an admin of the apiary to update it" + ); + } + + const newMember = await User.findOne({ email: req.body.email }); + var found = false; + apiary.members.forEach((member) => { + if (member.user._id.toString() === newMember._id.toString()) { + found = true; + res.status(400); + throw new Error("User is already a member of this apiary"); + } + }); + + var updatedApiary; + + if (!found && req.body.role != "CREATOR") { + // Push the new member :user_id to the apiary :apiary_id + updatedApiary = await Apiary.findByIdAndUpdate( + { _id: req.params.apiary_id }, + { + $push: { + members: { + user: newMember, + role: req.body.role, + }, + }, + }, + { + new: true, + } + ).populate("members.user"); + + res.status(200).json(updatedApiary); + } else { + res.status(400); + throw new Error("User role cannot be set to CREATOR"); + } +}); + +// @status WORKING +// @desc Update members to apiary +// @route PUT /api/apiaries/member/:apiary_id&:user_id&setOwner +// @access Private; admins of apiary only +const updateMember = asyncHandler(async (req, res) => { + const { user, role, apiary } = await checkUserToApiary(req, res); + // If not an admin or not the currently logged in user, unauthorized + if ( + (role != "CREATOR" && role != "ADMIN") || + user.toString() !== req.user.id + ) { + res.status(400); + throw new Error( + "User not authorized. User must be an admin of the apiary to update it" + ); + } + + var found = false; + apiary.members.forEach((member) => { + if (member.user.toString() === req.params.user_id) { + found = true; + return; + } + }); + + var updatedApiary; + + if (found && req.body.role != "CREATOR") { + updatedApiary = await Apiary.findOneAndUpdate( + { _id: req.params.apiary_id, "members.user": req.params.user_id }, + { + $set: { + "members.$.role": req.body.role, + }, + }, + { + new: true, + } + ).populate("members.user"); + res.status(200).json(updatedApiary); + } else { + res.status(400); + throw new Error("User not found"); + } +}); + +// @status WORKING +// @desc Delete member from apiary +// @route PUT /api/apiaries/member/:apiary_id&:user_id +// @access Private; admins of apiary only +const deleteMember = asyncHandler(async (req, res) => { + const { user, role, apiary } = await checkUserToApiary(req, res); + + // If not the admin or not the currently logged in user, unauthorized + if ( + (role != "CREATOR" && role != "ADMIN") || + user.toString() !== req.user.id + ) { + res.status(400); + throw new Error( + "User not authorized. User must be an admin of the apiary to update it" + ); + } + + apiary.members.forEach((member) => { + if ( + member.user.toString() === req.params.user_id && + member.role == "CREATOR" + ) { + res.status(400); + throw new Error("The creator of the apiary cannot be deleted"); + } + }); + + const updatedApiary = await Apiary.findByIdAndUpdate( + { _id: req.params.apiary_id }, + { + $pull: { + members: { + user: req.params.user_id, + }, + }, + }, + { + new: true, + } + ).populate("members.user"); + + if (!updatedApiary) { + res.status(400); + throw new Error("Member was not found"); + } + + res.status(200).json(updatedApiary); +}); + +module.exports = { + getApiaries, + getApiaryWithDeviceData, + setApiary, + updateApiary, + deleteApiary, + setDevice, + updateDevice, + deleteDevice, + setMember, + updateMember, + deleteMember, +}; diff --git a/backend/controllers/data.controller.js b/backend/controllers/data.controller.js new file mode 100644 index 0000000..46e0fe9 --- /dev/null +++ b/backend/controllers/data.controller.js @@ -0,0 +1,88 @@ +const asyncHandler = require("express-async-handler"); +const Apiary = require("../models/apiary.model.js"); +const Data = require("../models/data.model.js"); + +// get n last data points from array +const getData = asyncHandler(async (req, res) => { + const limit = req.query.limit === undefined ? 100 : req.query.limit; + + // Check if device exists + const deviceExists = await Apiary.findOne({ + devices: { + $elemMatch: { + serial: req.params.serial, + }, + }, + }); + + // If !deviceExists, error + if (!deviceExists) { + res.status(400); + throw new Error("Device does not exist"); + } + + const data = await Data.findOne({ serial: req.params.serial }, {datapoints: { $slice: -limit }}); + + if (limit > data.datapoints.length) { + res.status(204).send(); + return; + } + + res.status(200).json(data.datapoints); +}); + +// @status DONE +// @desc Upload data point +// @route POST /api/data/serial/:serial +// @access NEEDS PROTECTION (ML TEAM AUTHORIZED ONLY) +const putData = asyncHandler(async (req, res) => { + let { + time, + raw_activity, + weather, + prediction_activity, + last_prediction_deviation, + } = req.body; + + last_prediction_deviation ??= 0; + + // Check if device exists + const deviceExists = await Apiary.findOne({ + devices: { + $elemMatch: { + serial: req.params.serial, + }, + }, + }); + + // If !deviceExists, error + if (!deviceExists) { + res.status(400); + throw new Error("Device does not exist"); + } + + const updatedReport = await Data.updateOne( + { serial: req.params.serial }, + { + $push: { + datapoints: { + time: time, + raw_activity: raw_activity, + weather: weather, + prediction_activity: prediction_activity, + last_prediction_deviation: last_prediction_deviation, + }, + }, + }, + { + new: true, + } + ); + + res.status(200).json(updatedReport); +}); + +module.exports = { + getData, + putData, +}; diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js new file mode 100644 index 0000000..638d8fc --- /dev/null +++ b/backend/controllers/user.controller.js @@ -0,0 +1,101 @@ +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcryptjs"); +const asyncHandler = require("express-async-handler"); +const User = require("../models/user.model"); + +// @desc Register new user +// @route POST /api/users +// @access Public +const registerUser = asyncHandler(async (req, res) => { + const { name, email, password } = req.body; + + if (!name || !email || !password) { + res.status(400); + throw new Error("Please include all required fields"); + } + + // Check if user exists + const userExists = await User.findOne({ email }); + + if (userExists) { + res.status(400); + throw new Error("User already exists"); + } + + // Hash password w/ bcrypt + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + // Create user + const user = await User.create({ + name, + email, + password: hashedPassword, + }); + + // Check for user registration success + if (user) { + res.status(201).json({ + _id: user.id, + name: user.name, + email: user.email, + token: generateToken(user._id), + }); + } else { + res.status(400); + throw new Error("Invalid user data"); + } +}); + +// @desc Authenticate a user (login) +// @route POST /api/users/login +// @access Public +const loginUser = asyncHandler(async (req, res) => { + const { email, password } = req.body; + + if (!email || !password) { + res.status(400); + throw new Error("Please include all required fields"); + } + + // Check that user exists by email + const user = await User.findOne({ email }); + + if (user && (await bcrypt.compare(password, user.password))) { + res.status(201).json({ + _id: user.id, + name: user.name, + email: user.email, + token: generateToken(user._id), + }); + } else { + res.status(400); + throw new Error("Invalid email or password"); + } +}); + +// @desc Get user data +// @route GET /api/users/me +// @access Private +const getMe = asyncHandler(async (req, res) => { + const { _id, name, email } = await User.findById(req.user.id); + + res.status(200).json({ + id: _id, + name, + email, + }); +}); + +// Generate JWT +const generateToken = (id) => { + return jwt.sign({ id }, process.env.JWT_SECRET, { + expiresIn: "30d", + }); +}; + +module.exports = { + registerUser, + loginUser, + getMe, +}; diff --git a/backend/middleware/auth.middleware.js b/backend/middleware/auth.middleware.js new file mode 100644 index 0000000..e144a46 --- /dev/null +++ b/backend/middleware/auth.middleware.js @@ -0,0 +1,36 @@ +const jwt = require("jsonwebtoken"); +const asyncHandler = require("express-async-handler"); +const User = require("../models/user.model.js"); + +const protect = asyncHandler(async (req, res, next) => { + let token; + + if ( + req.headers.authorization && + req.headers.authorization.startsWith("Bearer") + ) { + try { + // Get token from header + token = req.headers.authorization.split(" ")[1]; + + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Get user from the token + req.user = await User.findById(decoded.id).select("-password"); + + next(); + } catch (error) { + console.log(error); + res.status(401); + throw new Error("Not authorized"); + } + } + + if (!token) { + res.status(401); + throw new Error("Not authorized, no token"); + } +}); + +module.exports = { protect }; diff --git a/backend/middleware/error.middleware.js b/backend/middleware/error.middleware.js new file mode 100644 index 0000000..c4d33de --- /dev/null +++ b/backend/middleware/error.middleware.js @@ -0,0 +1,14 @@ +const errorHandler = (err, req, res, next) => { + const statusCode = res.statusCode ? res.statusCode : 500; + + res.status(statusCode); + + res.json({ + message: err.message, + stack: process.env.NODE_ENV === "production" ? null : err.stack, + }); +}; + +module.exports = { + errorHandler, +}; diff --git a/backend/models/apiary.model.js b/backend/models/apiary.model.js new file mode 100644 index 0000000..aa5c4b3 --- /dev/null +++ b/backend/models/apiary.model.js @@ -0,0 +1,110 @@ +const mongoose = require("mongoose"); + +const geoSchema = mongoose.Schema({ + type: { + type: String, + default: "Point", + }, + coordinates: { + type: [Number], //the type is an array of numbers + index: "2dsphere", + }, + formattedAddress: { + type: String, + }, + placeID: { + type: String, + }, +}); + +const memberSchema = mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + required: [true, "Please add a creator"], + ref: "User", + }, + role: { + type: String, + default: "USER", + enum: ["USER", "ADMIN", "CREATOR"], + }, +}); + +const dataSchema = mongoose.Schema( + { + time: Date, + intake: Number, + outtake: Number, + }, + { + timeseries: { + timeField: "time", + granularity: "minutes", + }, + } +); + +const deviceSchema = new mongoose.Schema( + { + serial: { + type: String, + required: [true, "Please add a serial number"], + unique: true, + // partialFilterExpression: { serial: { $type: "string" } }, + sparse: true, + immutable: true, + }, + name: { + type: String, + required: [true, "Please add a name"], + }, + remote: { + type: String, + required: [true, "Please add a remote.it URL"], + unique: true, + // partialFilterExpression: { remote: { $type: "string" } }, + sparse: true, + }, + data: { + type: mongoose.Schema.Types.ObjectId, + ref: "Data", + }, + }, + { + timestamps: true, + } +); + +function arrayLimit(val) { + return val.length <= 10; +} + +const apiarySchema = new mongoose.Schema( + { + name: { + type: String, + // required: [true, "Please add a name"], + }, + location: { + type: geoSchema, + // required: [true, "Please add a location"], + }, + members: [ + { + type: memberSchema, + required: [true, "Please add a member"], + validate: [arrayLimit, "Members exceeds the limit of 10"], + }, + ], + devices: [ + { + type: deviceSchema, + }, + ], + }, + { + timestamps: true, + } +); + +module.exports = mongoose.model("Apiary", apiarySchema); diff --git a/backend/models/data.model.js b/backend/models/data.model.js new file mode 100644 index 0000000..ad5b645 --- /dev/null +++ b/backend/models/data.model.js @@ -0,0 +1,45 @@ +const mongoose = require("mongoose"); + +// Define the data point schema +const dataPointSchema = new mongoose.Schema({ + time: { type: Date, default: Date.now }, + raw_activity: { + x: { type: Number, required: true }, + y: { type: Number, required: true }, + }, + weather: { + temp: { type: Number, required: true }, + humidity: { type: Number, required: true }, + windspeed: { type: Number, required: true }, + }, + prediction_activity: { + x: { type: Number, required: true }, + y: { type: Number, required: true }, + }, + last_prediction_deviation: { + type: Number, + required: false, + }, +}); + +const dataSchema = new mongoose.Schema({ + apiary: { + type: mongoose.Schema.Types.ObjectId, + ref: "Apiary", + }, + serial: { + type: String, + required: [true, "Please add a serial number"], + unique: true, + // partialFilterExpression: { serial: { $type: "string" } }, + sparse: true, + immutable: true, + }, + datapoints: [ + { + type: dataPointSchema, + }, + ], +}); + +module.exports = mongoose.model("Data", dataSchema); diff --git a/backend/models/user.model.js b/backend/models/user.model.js new file mode 100644 index 0000000..d372a4b --- /dev/null +++ b/backend/models/user.model.js @@ -0,0 +1,24 @@ +const mongoose = require("mongoose"); + +const userSchema = new mongoose.Schema( + { + name: { + type: String, + required: [true, "Please add a username"], + }, + email: { + type: String, + required: [true, "Please add an email"], + unique: true, + }, + password: { + type: String, + required: [true, "Please add a password"], + }, + }, + { + timestamps: true, + } +); + +module.exports = mongoose.model("User", userSchema); diff --git a/backend/routes/apiary.routes.js b/backend/routes/apiary.routes.js new file mode 100644 index 0000000..819f682 --- /dev/null +++ b/backend/routes/apiary.routes.js @@ -0,0 +1,51 @@ +const router = require("express").Router(); +const { + getApiaries, + getApiaryWithDeviceData, + setApiary, + updateApiary, + deleteApiary, + setDevice, + updateDevice, + deleteDevice, + setMember, + updateMember, + deleteMember, +} = require("../controllers/apiary.controller.js"); + +const { protect } = require("../middleware/auth.middleware"); + +// apiary requests +router.get("/", protect, getApiaries); +router.get("/filter/:filter", protect, getApiaryWithDeviceData); +router.post("/", protect, setApiary); +router.put("/apiary/:apiary_id", protect, updateApiary); +router.delete("/apiary/:apiary_id", protect, deleteApiary); + +// device requests +router.put("/apiary/:apiary_id/setdevice", protect, setDevice); +router.put( + "/apiary/:apiary_id/device/:device_id/updatedevice", + protect, + updateDevice +); +router.put( + "/apiary/:apiary_id/device/:device_id/serial/:serial/deletedevice", + protect, + deleteDevice +); + +// member requests +router.put("/apiary/:apiary_id/setmember", protect, setMember); +router.put( + "/apiary/:apiary_id/user/:user_id/updatemember", + protect, + updateMember +); +router.put( + "/apiary/:apiary_id/user/:user_id/deletemember", + protect, + deleteMember +); + +module.exports = router; diff --git a/backend/routes/data.routes.js b/backend/routes/data.routes.js new file mode 100644 index 0000000..f3ff0c3 --- /dev/null +++ b/backend/routes/data.routes.js @@ -0,0 +1,7 @@ +const router = require("express").Router(); +const { getData, putData } = require("../controllers/data.controller.js"); + +router.get("/serial/:serial", getData); +router.put("/serial/:serial", putData); + +module.exports = router; diff --git a/backend/routes/user.routes.js b/backend/routes/user.routes.js new file mode 100644 index 0000000..466ee09 --- /dev/null +++ b/backend/routes/user.routes.js @@ -0,0 +1,13 @@ +const router = require("express").Router(); +const { + registerUser, + loginUser, + getMe, +} = require("../controllers/user.controller.js"); +const { protect } = require("../middleware/auth.middleware.js"); + +router.post("/register", registerUser); +router.post("/login", loginUser); +router.get("/me", protect, getMe); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..0a9efa2 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,45 @@ +const path = require("path"); +const express = require("express"); +const mongoose = require("mongoose"); +const colors = require("colors"); +const dotenv = require("dotenv"); +dotenv.config({ path: __dirname + "/.env" }); +const connectDB = require("./config/db"); +const { errorHandler } = require("./middleware/error.middleware"); +const port = process.env.PORT || 5000; + +connectDB(); + +const app = express(); + +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); + +const userRouter = require("./routes/user.routes.js"); +const apiaryRouter = require("./routes/apiary.routes.js"); +const dataRouter = require("./routes/data.routes.js"); + + +app.use("/api/users", userRouter); +app.use("/api/apiaries", apiaryRouter); +app.use("/api/data", dataRouter); + + +// Serve frontend +if (process.env.NODE_ENV === "production") { + app.use(express.static(path.join(__dirname, "../frontend/build"))); + + app.get("*", (req, res) => + res.sendFile( + path.resolve(__dirname, "../", "frontend", "build", "index.html") + ) + ); +} else { + app.get("/", (req, res) => res.send("Please set to production")); +} + +app.use(errorHandler); + +app.listen(port, () => { + console.log(`Server is running on port: ${port}`); +}); diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..de6c0e1 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,46 @@ +# Getting Started with Create React App and Redux + +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) template. + +## Available Scripts + +In the project directory, you can run: + +### `npm start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in your browser. + +The page will reload when you make changes.\ +You may also see any lint errors in the console. + +### `npm test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `npm run build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `npm run eject` + +**Note: this is a one-way operation. Once you `eject`, you can't go back!** + +If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. + +You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c50aeb1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,68 @@ +{ + "name": "frontend", + "version": "0.1.0", + "proxy": "http://localhost:5000", + "private": true, + "dependencies": { + "@emotion/react": "^11.10.6", + "@emotion/styled": "^11.10.6", + "@mui/icons-material": "^5.11.9", + "@mui/joy": "^5.0.0-alpha.67", + "@mui/material": "^5.11.9", + "@reduxjs/toolkit": "^1.9.2", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", + "@types/jest": "^29.5.1", + "@types/node": "^18.16.3", + "@types/react": "^18.2.3", + "@types/react-dom": "^18.2.3", + "autosuggest-highlight": "^3.3.4", + "axios": "^1.3.3", + "date-fns": "^2.29.3", + "lodash": "^4.17.21", + "material-ui-autocomplete-google-places": "^2.3.0", + "mui-address-autocomplete": "^3.0.4", + "mui-places-autocomplete": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^4.7.1", + "react-open-weather": "^1.3.0", + "react-places-autocomplete": "^7.3.0", + "react-pro-sidebar": "^0.7.1", + "react-redux": "^8.0.5", + "react-router-dom": "^6.8.1", + "react-scripts": "5.0.1", + "react-toastify": "^9.1.1", + "recharts": "^2.5.0", + "typescript": "^5.0.4", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "sass": "^1.60.0" + } +} diff --git a/frontend/public/about-ss.PNG b/frontend/public/about-ss.PNG new file mode 100644 index 0000000..9fdcd76 Binary files /dev/null and b/frontend/public/about-ss.PNG differ diff --git a/frontend/public/db-ss.PNG b/frontend/public/db-ss.PNG new file mode 100644 index 0000000..4bcaf4b Binary files /dev/null and b/frontend/public/db-ss.PNG differ diff --git a/frontend/public/faq-ss.PNG b/frontend/public/faq-ss.PNG new file mode 100644 index 0000000..87043fd Binary files /dev/null and b/frontend/public/faq-ss.PNG differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..354202a Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..17318ef --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + Beehive Monitor Dashboard + + + +
+ + + diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png new file mode 100644 index 0000000..3362410 Binary files /dev/null and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png new file mode 100644 index 0000000..b351622 Binary files /dev/null and b/frontend/public/logo512.png differ diff --git a/frontend/public/manage-ss.PNG b/frontend/public/manage-ss.PNG new file mode 100644 index 0000000..51e456f Binary files /dev/null and b/frontend/public/manage-ss.PNG differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..01b0f9a --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/frontend/public/signin-ss.PNG b/frontend/public/signin-ss.PNG new file mode 100644 index 0000000..92b229a Binary files /dev/null and b/frontend/public/signin-ss.PNG differ diff --git a/frontend/public/signup-ss.PNG b/frontend/public/signup-ss.PNG new file mode 100644 index 0000000..3924939 Binary files /dev/null and b/frontend/public/signup-ss.PNG differ diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..ac83b16 --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,131 @@ +import React from "react"; +import { useState } from "react"; +import { ColorModeContext, useMode } from "./theme"; +import { CssBaseline, ThemeProvider } from "@mui/material"; +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import Topbar from "./components/Topbar.jsx"; +import Sidebar from "./components/Sidebar.jsx"; +import Dashboard from "./pages/Dashboard"; +import Login from "./pages/Login"; +import Register from "./pages/Register"; +import Manage from "./pages/Manage"; +import FAQ from "./pages/FAQ"; +import About from "./pages/About"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + +function App() { + const [theme, colorMode] = useMode(); + const [isSidebar, setIsSidebar] = useState(true); + + return ( + <> + + + + + + + +
+ +
+ + +
+
+ + } + /> + + { +
+ +
+ + +
+
+ } + + } + /> + + { +
+ + +
+ + +
+
+ } + + } + /> + + { +
+ + +
+ + +
+
+ } + + } + /> + +
+
+ +
+
+ + } + /> + +
+
+ +
+
+ + } + /> +
+ + +
+
+
+ + ); +} + +export default App; diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js new file mode 100644 index 0000000..659cc13 --- /dev/null +++ b/frontend/src/App.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { store } from './app/store'; +import App from './App'; + +test('renders learn react link', () => { + const { getByText } = render( + + + + ); + + expect(getByText(/learn/i)).toBeInTheDocument(); +}); diff --git a/frontend/src/app/store.js b/frontend/src/app/store.js new file mode 100644 index 0000000..a1d795e --- /dev/null +++ b/frontend/src/app/store.js @@ -0,0 +1,10 @@ +import { configureStore } from "@reduxjs/toolkit"; +import authReducer from "../features/auth/auth.slice.js"; +import apiaryReducer from "../features/apiary/apiary.slice.js"; + +export const store = configureStore({ + reducer: { + auth: authReducer, + apiary: apiaryReducer, + }, +}); diff --git a/frontend/src/components/AboutCard.jsx b/frontend/src/components/AboutCard.jsx new file mode 100644 index 0000000..20dd033 --- /dev/null +++ b/frontend/src/components/AboutCard.jsx @@ -0,0 +1,107 @@ +import { + Grid, + Avatar, + Card, + CardActions, + CardHeader, + Collapse, + IconButton, + Typography, + useTheme, + Box, +} from "@mui/material"; +import ArrowDropDownOutlinedIcon from "@mui/icons-material/ArrowDropDownOutlined"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { useState } from "react"; +import { tokens } from "../theme"; + +const AboutCard = ({ faq }) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [expand, setExpand] = useState(); + + return ( + + + + + } + title={ {faq[0]} } + action={ + + + setExpand(!expand)}> + + + + + } + /> + + + + {" "} + {faq[1]}{" "} + + + + + ); +}; + +export default AboutCard; diff --git a/frontend/src/components/AddApiaryCard.jsx b/frontend/src/components/AddApiaryCard.jsx new file mode 100644 index 0000000..7b59edb --- /dev/null +++ b/frontend/src/components/AddApiaryCard.jsx @@ -0,0 +1,147 @@ +import { + Grid, + Avatar, + Card, + CardActions, + Collapse, + IconButton, + useTheme, + Button, + TextField, + Box, +} from "@mui/material"; +import AddOutlinedIcon from "@mui/icons-material/AddOutlined"; +import { useState } from "react"; +import { tokens } from "../theme"; +import { useDispatch } from "react-redux"; +import { setApiary } from "../features/apiary/apiary.slice"; +import React from "react"; +import GoogleMaps from "./AutocompleteMaps.tsx"; + +const AddApiaryCard = () => { + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [value, setValue] = React.useState(); + + const [expand, setExpand] = useState(false); + + const [formData, setFormData] = useState({ + name: "", + }); + + const { name } = formData; + + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + })); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + + const apiaryData = { + name: name, + location: { + coordinates: [0, 0], + formattedAddress: value.description, + placeID: value.place_id, + }, + }; + + dispatch(setApiary(apiaryData)); + }; + + return ( + + + + setExpand(!expand)}> + + + + + + + + + + + + + + + + + ); +}; + +export default AddApiaryCard; diff --git a/frontend/src/components/AddDeviceCard.jsx b/frontend/src/components/AddDeviceCard.jsx new file mode 100644 index 0000000..7c752e8 --- /dev/null +++ b/frontend/src/components/AddDeviceCard.jsx @@ -0,0 +1,162 @@ +import { + Grid, + Avatar, + Card, + CardActions, + Collapse, + IconButton, + useTheme, + Button, + TextField, + Box, +} from "@mui/material"; +import AddOutlinedIcon from "@mui/icons-material/AddOutlined"; +import { useState } from "react"; +import { tokens } from "../theme"; +import { useDispatch } from "react-redux"; +import { setDevice } from "../features/apiary/apiary.slice"; + +const AddDeviceCard = ({ apiary, userRole }) => { + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [expand, setExpand] = useState(false); + + const [formData, setFormData] = useState({ + serial: "", + name: "", + remote: "", + }); + + const { name, serial, remote } = formData; + + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + })); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + + const apiaryData = { + deviceData: formData, + apiaryID: apiary._id, + }; + + dispatch(setDevice(apiaryData)); + }; + + return ( + + + + setExpand(!expand)}> + + + + + + + + + + + + + + + + + + ); +}; + +export default AddDeviceCard; diff --git a/frontend/src/components/AddUserCard.jsx b/frontend/src/components/AddUserCard.jsx new file mode 100644 index 0000000..c3fb014 --- /dev/null +++ b/frontend/src/components/AddUserCard.jsx @@ -0,0 +1,154 @@ +import { + Grid, + Avatar, + Card, + CardActions, + Collapse, + IconButton, + useTheme, + Button, + TextField, + Box, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import AddOutlinedIcon from "@mui/icons-material/AddOutlined"; +import { useState } from "react"; +import { tokens } from "../theme"; +import { useDispatch } from "react-redux"; +import { setMember } from "../features/apiary/apiary.slice"; + +const AddUserCard = ({ apiary, userRole }) => { + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [expand, setExpand] = useState(false); + + const [formData, setFormData] = useState({ + email: "", + role: "", + }); + + const { email, isChecked } = formData; + + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + isChecked: e.target.checked, + })); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + + const userData = { + email, + role: isChecked ? "ADMIN" : "USER", + apiaryID: apiary._id, + }; + + dispatch(setMember(userData)); + }; + + return ( + + + + setExpand(!expand)}> + + + + + + + + + + + + } + label="Set as administrator" + onChange={onChange} + checked={isChecked} + fullWidth + /> + + + + + + ); +}; + +export default AddUserCard; diff --git a/frontend/src/components/ApiaryCard.jsx b/frontend/src/components/ApiaryCard.jsx new file mode 100644 index 0000000..52b19bc --- /dev/null +++ b/frontend/src/components/ApiaryCard.jsx @@ -0,0 +1,323 @@ +import { + Grid, + Avatar, + Card, + CardActions, + CardHeader, + Collapse, + IconButton, + Typography, + useTheme, + Button, + TextField, + Box, +} from "@mui/material"; +import GroupOutlinedIcon from "@mui/icons-material/GroupOutlined"; +import DeviceHubOutlinedIcon from "@mui/icons-material/DeviceHubOutlined"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { useState, useEffect } from "react"; +import { tokens } from "../theme"; +import { deleteApiary, updateApiary } from "../features/apiary/apiary.slice"; +import { useDispatch, useSelector } from "react-redux"; +import DeviceCard from "./DeviceCard"; +import UserCard from "./UserCard"; +import AddDeviceCard from "./AddDeviceCard"; +import AddUserCard from "./AddUserCard"; + +const checkUser = async (apiary, user) => { + var role = "USER"; + apiary.members.forEach((member) => { + if (member.user._id === user._id) { + role = member.role; + return; + } + }); + return role; +}; + +const ApiaryCard = ({ apiary }) => { + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const { user } = useSelector((state) => state.auth); + + const [expand, setExpand] = useState(false); + + const [formData, setFormData] = useState({ + name: apiary.name, + location: apiary.location, + }); + + const { name, location } = formData; + + const getUserRole = async () => { + const role = await checkUser(apiary, user); + setUserRole(role); + }; + + const [userRole, setUserRole] = useState(""); + useEffect(() => { + getUserRole(); + }, []); + + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + })); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + + const apiaryID = apiary._id; + + const apiaryData = { + name, + location, + apiaryID, + }; + + dispatch(updateApiary(apiaryData)); + }; + + const onDelete = async (e) => { + e.preventDefault(); + + const data = { + apiaryID: apiary._id, + }; + + dispatch(deleteApiary(data)); + }; + + return ( + + + {apiary.name.charAt(0).toUpperCase()} + + } + title={ {apiary.name} } + subheader={apiary.location.formattedAddress} + action={ + + + setExpand(!expand)}> + + + + + } + /> + + + + + + + + + + + + + + + {/* MANAGE DEVICES */} + + + + + + Devices + +
+ {apiary.devices.map((device) => ( + + ))} +
+ +
+
+ + {/* MANAGE MEMBERS */} + + + + + + Beekeepers + +
+ {apiary.members.map((user) => ( + + ))} +
+ +
+
+
+
+
+ ); +}; + +export default ApiaryCard; diff --git a/frontend/src/components/AutocompleteMaps.tsx b/frontend/src/components/AutocompleteMaps.tsx new file mode 100644 index 0000000..c02e524 --- /dev/null +++ b/frontend/src/components/AutocompleteMaps.tsx @@ -0,0 +1,185 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import TextField from "@mui/material/TextField"; +import Autocomplete from "@mui/material/Autocomplete"; +import LocationOnIcon from "@mui/icons-material/LocationOn"; +import Grid from "@mui/material/Grid"; +import Typography from "@mui/material/Typography"; +import parse from "autosuggest-highlight/parse"; +import { debounce } from "@mui/material/utils"; + +// This key was created specifically for the demo in mui.com. +// You need to create a new one for your application. + +function loadScript(src: string, position: HTMLElement | null, id: string) { + if (!position) { + return; + } + + const script = document.createElement("script"); + script.setAttribute("async", ""); + script.setAttribute("id", id); + script.src = src; + position.appendChild(script); +} + +const autocompleteService = { current: null }; + +interface MainTextMatchedSubstrings { + offset: number; + length: number; +} +interface StructuredFormatting { + main_text: string; + secondary_text: string; + main_text_matched_substrings?: readonly MainTextMatchedSubstrings[]; +} +interface PlaceType { + description: string; + structured_formatting: StructuredFormatting; +} + +export default function GoogleMaps({ value, setValue }) { + const [inputValue, setInputValue] = React.useState(""); + const [options, setOptions] = React.useState([]); + const loaded = React.useRef(false); + + if (typeof window !== "undefined" && !loaded.current) { + if (!document.querySelector("#google-maps")) { + loadScript( + `https://maps.googleapis.com/maps/api/js?key=AIzaSyDzw9VWbQ3m-DooVKAEzdl9Wmgy5zoPEgc&libraries=places`, + document.querySelector("head"), + "google-maps" + ); + } + + loaded.current = true; + } + + const fetch = React.useMemo( + () => + debounce( + ( + request: { input: string }, + callback: (results?: readonly PlaceType[]) => void + ) => { + (autocompleteService.current as any).getPlacePredictions( + request, + callback + ); + }, + 400 + ), + [] + ); + + React.useEffect(() => { + let active = true; + + if (!autocompleteService.current && (window as any).google) { + autocompleteService.current = new ( + window as any + ).google.maps.places.AutocompleteService(); + } + if (!autocompleteService.current) { + return undefined; + } + + if (inputValue === "") { + setOptions(value ? [value] : []); + return undefined; + } + + fetch({ input: inputValue }, (results?: readonly PlaceType[]) => { + if (active) { + let newOptions: readonly PlaceType[] = []; + + if (value) { + newOptions = [value]; + } + + if (results) { + newOptions = [...newOptions, ...results]; + } + + setOptions(newOptions); + } + }); + + return () => { + active = false; + }; + }, [value, inputValue, fetch]); + + return ( + + typeof option === "string" ? option : option.description + } + filterOptions={(x) => x} + options={options} + autoComplete + includeInputInList + filterSelectedOptions + value={value} + noOptionsText="No locations found" + onChange={(event: any, newValue: PlaceType | null) => { + setOptions(newValue ? [newValue, ...options] : options); + setValue(newValue); + }} + onInputChange={(event, newInputValue) => { + setInputValue(newInputValue); + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const matches = + option.structured_formatting.main_text_matched_substrings || []; + + const parts = parse( + option.structured_formatting.main_text, + matches.map((match: any) => [ + match.offset, + match.offset + match.length, + ]) + ); + // console.log(option); + + return ( +
  • + + + + + + {parts.map((part, index) => ( + + {part.text} + + ))} + + {option.structured_formatting.secondary_text} + + + +
  • + ); + }} + /> + ); +} diff --git a/frontend/src/components/CustomTooltip.jsx b/frontend/src/components/CustomTooltip.jsx new file mode 100644 index 0000000..6bef17e --- /dev/null +++ b/frontend/src/components/CustomTooltip.jsx @@ -0,0 +1,29 @@ +import { parseISO, format } from "date-fns"; +import { Box, Typography } from "@mui/material"; + +const CustomTooltip = ({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( + + + {format(parseISO(label), "eeee, d MMM yyyy")} + + {payload.map((p) => ( + + {p.name}: {p.value} {p.unit} + + ))} + + ); + } + + return null; +}; + +export default CustomTooltip; diff --git a/frontend/src/components/DeviceCard.jsx b/frontend/src/components/DeviceCard.jsx new file mode 100644 index 0000000..66f3516 --- /dev/null +++ b/frontend/src/components/DeviceCard.jsx @@ -0,0 +1,220 @@ +import { + Grid, + Avatar, + Card, + CardActions, + CardHeader, + Collapse, + IconButton, + Typography, + TextField, + useTheme, + Box, + Button, +} from "@mui/material"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { useState } from "react"; +import { tokens } from "../theme"; +import { useDispatch } from "react-redux"; +import { updateDevice, deleteDevice } from "../features/apiary/apiary.slice"; + +const DeviceCard = ({ device, apiary, userRole }) => { + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [expand, setExpand] = useState(); + + const [formData, setFormData] = useState({ + serial: device.serial, + name: device.name, + remote: device.remote, + }); + + const { name, serial, remote } = formData; + + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + })); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + + const apiaryData = { + name, + serial, + remote, + apiaryID: apiary._id, + deviceID: device._id, + }; + + dispatch(updateDevice(apiaryData)); + }; + + const onDelete = async (e) => { + e.preventDefault(); + + const data = { + apiaryID: apiary._id, + deviceID: device._id, + serial: device.serial, + }; + + dispatch(deleteDevice(data)); + }; + + return ( + + + {device.name.charAt(0).toUpperCase()} + + } + title={ {device.name} } + subheader={device.serial} + action={ + + + setExpand(!expand)}> + + + + + } + /> + + + + + + + + + + + + + + + ); +}; + +export default DeviceCard; diff --git a/frontend/src/components/FAQCard.jsx b/frontend/src/components/FAQCard.jsx new file mode 100644 index 0000000..ae37abc --- /dev/null +++ b/frontend/src/components/FAQCard.jsx @@ -0,0 +1,107 @@ +import { + Grid, + Avatar, + Card, + CardActions, + CardHeader, + Collapse, + IconButton, + Typography, + useTheme, + Box, +} from "@mui/material"; +import ArrowDropDownOutlinedIcon from "@mui/icons-material/ArrowDropDownOutlined"; +import HelpOutlineOutlinedIcon from "@mui/icons-material/HelpOutlineOutlined"; +import { useState } from "react"; +import { tokens } from "../theme"; + +const FAQCard = ({ faq }) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [expand, setExpand] = useState(); + + return ( + + + + + } + title={ {faq[0]} } + action={ + + + setExpand(!expand)}> + + + + + } + /> + + + + {" "} + {faq[1]}{" "} + + + + + ); +}; + +export default FAQCard; diff --git a/frontend/src/components/Graph.jsx b/frontend/src/components/Graph.jsx new file mode 100644 index 0000000..0546b21 --- /dev/null +++ b/frontend/src/components/Graph.jsx @@ -0,0 +1,299 @@ +import { + ResponsiveContainer, + AreaChart, + XAxis, + YAxis, + Area, + Tooltip, + CartesianGrid, + Legend, +} from "recharts"; +import { parseISO, format } from "date-fns"; +import { Typography, useTheme, Button } from "@mui/material"; +import { tokens } from "../theme"; +import { useState } from "react"; +import CustomTooltip from "./CustomTooltip"; + +const Graph = ({ + device, + selectedFilter, + setSelectedFilter, + filterOptions, +}) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [visible, setVisible] = useState({ + x: true, + y: true, + temp: true, + humidity: true, + windspeed: true, + }); + + const toggleVisibility = (e) => { + setVisible({ + ...visible, + [e.dataKey]: !visible[e.dataKey], + hover: null, + }); + }; + + const [displayedDates, setDisplayedDates] = useState([]); + + if (!device) { + return ( + + Select a device to view data + + ); + } else { + console.log(device); + + let dp = []; + + if (device?.data?.datapoints?.length > 0) { + dp = device.data.datapoints; + } + + console.log(dp); + + const time = dp.map((dataPoint) => dataPoint.time); // extract time value from each data point + + const temp = dp + .map((dataPoint) => Math.round(dataPoint.weather.temp * 100) / 100) // round temp value to the hundredths place + .filter((temp) => typeof temp === "number"); // remove any non-number temp values + + const humidity = dp + .map((dataPoint) => dataPoint.weather.humidity) // extract humidity value from each data point + .filter((humidity) => typeof humidity === "number"); // remove any non-number humidity values + + const windspeed = dp + .map((dataPoint) => dataPoint.weather.windspeed) // extract windspeed value from each data point + .filter((windspeed) => typeof windspeed === "number"); // remove any non-number windspeed values + + const x = dp + .map((dp) => dp.raw_activity.x) + .filter((x) => typeof x === "number"); // remove any non-number x values; + + const y = dp + .map((dp) => dp.raw_activity.y) + .filter((y) => typeof y === "number"); // remove any non-number y values; + + const result = x.map((value, index) => { + return { + time: time[index], + x: value, + y: y[index], + temp: temp[index], + humidity: humidity[index], + windspeed: windspeed[index], + }; + }); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `${number}°C`} + /> + + `${number}%`} + /> + + `${number}km/h`} + /> + + + { + const date = parseISO(str); + const formattedDate = format(date, "MMM, d"); + + // Check if date has already been displayed + if ( + date.getDate() % 7 !== 0 || + displayedDates.includes(formattedDate) + ) { + return ""; + } else { + // Add date to displayed dates array + setDisplayedDates([...displayedDates, formattedDate]); + console.log(formattedDate); + + return formattedDate; + } + }} + /> + } /> + + + + +
    + {filterOptions.map(({ label, code, value }) => ( + + ))} +
    + + ); + } +}; + +export default Graph; diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..12adc51 --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -0,0 +1,24 @@ +import { Typography, Box, useTheme } from "@mui/material"; +import { tokens } from "../theme"; + +const Header = ({ title, subtitle }) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + return ( + + + {title} + + + {subtitle} + + + ); +}; + +export default Header; diff --git a/frontend/src/components/Loading.jsx b/frontend/src/components/Loading.jsx new file mode 100644 index 0000000..be90fae --- /dev/null +++ b/frontend/src/components/Loading.jsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import CircularProgress from "@mui/material/CircularProgress"; +import { useTheme } from "@mui/material/styles"; +import { tokens } from "../theme.js"; + +function Spinner() { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + return ; +} + +export default Spinner; diff --git a/frontend/src/components/Overview.jsx b/frontend/src/components/Overview.jsx new file mode 100644 index 0000000..fe003c1 --- /dev/null +++ b/frontend/src/components/Overview.jsx @@ -0,0 +1,378 @@ +import React, { useEffect, useState } from "react"; +import { useTheme } from "@mui/material/styles"; +import { tokens } from "../theme"; +import { + Typography, + Avatar, + Box, + Card, + CardHeader, + Fade, + Backdrop, +} from "@mui/material"; +import CheckCircleOutlineOutlinedIcon from "@mui/icons-material/CheckCircleOutlineOutlined"; +import CancelOutlinedIcon from "@mui/icons-material/CancelOutlined"; +import AddCircleOutlineOutlinedIcon from "@mui/icons-material/AddCircleOutlineOutlined"; +import RemoveCircleOutlineOutlinedIcon from "@mui/icons-material/RemoveCircleOutlineOutlined"; +import { useSelector } from "react-redux"; +import Loading from "./Loading"; + +const Overview = ({ device }) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const { apiaries, isLoading, isError, message } = useSelector( + (state) => state.apiary + ); + + const [isOnline, setIsOnline] = useState(false); + const [offlineTime, setOfflineTime] = useState(null); + + // gets the accumulated activity of the device in the last 24 hours + const getAccumulatedActivity = (device) => { + const twentyFourHoursAgo = new Date(); + twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24); + + const relevantDataPoints = device?.data?.datapoints?.filter((datapoint) => { + const datapointTime = new Date(datapoint.time); + datapointTime.setHours(datapointTime.getHours() + 7); // UTC to PST + + return datapointTime >= twentyFourHoursAgo; + }); + + if (!relevantDataPoints || relevantDataPoints.length === 0) { + return { x: 0, y: 0 }; + } + + const accumulatedActivity = relevantDataPoints.reduce( + (acc, datapoint) => { + const { x, y } = datapoint.raw_activity; + return { x: acc.x + x, y: acc.y + y }; + }, + { x: 0, y: 0 } + ); + + return accumulatedActivity; + }; + + const { x, y } = getAccumulatedActivity(device); + + // check if device is online + useEffect(() => { + if (device?.data?.datapoints) { + let lastDataPoint = + device?.data.datapoints[device?.data.datapoints.length - 1]; + + if (!lastDataPoint) { + setIsOnline(false); + setOfflineTime(null); + return; + } + const lastDataPointTime = new Date(lastDataPoint.time); + lastDataPointTime.setHours(lastDataPointTime.getHours() + 7); // UTC+7 + const currentTime = new Date(); + + const timeDiff = + (currentTime.getTime() - lastDataPointTime.getTime()) / 1000; + const isWithin = timeDiff <= 60 * 60 * 3; // last data point within 3 hours + + setIsOnline(isWithin); + setOfflineTime(isWithin ? null : timeDiff); + } + }, [device, setIsOnline]); + + // if no device is selected, show message + if (!device) { + return ( + + Select a device to view overview + + ); + } else if (isLoading) { + // if loading, show loading screen + return ( + + theme.zIndex.drawer + 1, + }} + > + + + + ); + } else { + // if device is selected, show overview + const formatOfflineTime = (offlineTime) => { + if (!offlineTime) { + return ""; + } + + const days = Math.floor(offlineTime / 86400); + const hours = Math.floor((offlineTime % 86400) / 3600); + const minutes = Math.floor((offlineTime % 3600) / 60); + + const dayString = days > 0 ? `${days} day${days > 1 ? "s" : ""}` : ""; + const hourString = + hours > 0 ? `${hours} hour${hours > 1 ? "s" : ""}` : ""; + const minuteString = + minutes > 0 ? `${minutes} minute${minutes > 1 ? "s" : ""}` : ""; + + const timeStrings = [dayString, hourString, minuteString].filter( + (str) => str !== "" + ); + + return timeStrings.join(", "); + }; + + return ( +
    + {"Overview"} + {isOnline ? ( + + + + + + } + title={Online} + /> + + + + + + } + title={ + + {x} bees entered the hive in the last 24 hours + + } + /> + + + + + + } + title={ + + {y} bees exited the hive in the last 24 hours + + } + /> + + + ) : ( + + + + + + } + title={ + + Offline for {formatOfflineTime(offlineTime)} + + } + /> + + + + + + } + title={ + + {x} bees entered the hive in the last 24 hours + + } + /> + + + + + + } + title={ + + {y} bees exited the hive in the last 24 hours + + } + /> + + + )} +
    + ); + } +}; + +export default Overview; diff --git a/frontend/src/components/SelectApiary.jsx b/frontend/src/components/SelectApiary.jsx new file mode 100644 index 0000000..8336304 --- /dev/null +++ b/frontend/src/components/SelectApiary.jsx @@ -0,0 +1,61 @@ +import { + Box, + useTheme, + Select, + FormControl, + InputLabel, + MenuItem, +} from "@mui/material"; +import { tokens } from "../theme"; + +const SelectApiary = ({ + apiaries, + apiary, + device, + setApiary, + setDevice, + setSelectedFilter, + selectedFilter, + filterOptions, +}) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const onChange = (e) => { + setApiary(e.target.value); + }; + + const onChange2 = (e) => { + setDevice(e.target.value); + setSelectedFilter(filterOptions[0]); + }; + + return ( + + + Apiary + + + + Device + + + + ); +}; + +export default SelectApiary; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..b5384e7 --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,304 @@ +import { useState, useEffect } from "react"; +import { Link, NavLink, useNavigate } from "react-router-dom"; +import { + ProSidebar, + Menu, + MenuItem, + SubMenu, + SidebarHeader, + SidebarFooter, + SidebarContent, + Sidebar as sb, +} from "react-pro-sidebar"; +import { + Box, + IconButton, + Typography, + useTheme, + createTheme, + Backdrop, +} from "@mui/material"; +import "../custom.scss"; +import { tokens } from "../theme"; +import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import MenuOutlinedIcon from "@mui/icons-material/MenuOutlined"; +import HiveOutlinedIcon from "@mui/icons-material/HiveOutlined"; +import HelpOutlineOutlinedIcon from "@mui/icons-material/HelpOutlineOutlined"; +import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; +import Avatar from "@mui/material/Avatar"; +import { useDispatch, useSelector } from "react-redux"; +import { + getApiaries, + reset as apiaryReset, +} from "../features/apiary/apiary.slice"; + +const Item = ({ title, to, icon, selected, setSelected }) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + return ( + setSelected(title)} + icon={ + + {icon} + + } + > + {title} + + + ); +}; + +// const DeviceItem = ({ device, to, selected, setSelected }) => { +// const theme = useTheme(); +// const colors = tokens(theme.palette.mode); + +// return ( +// setSelected(device.name)} +// > +// {device.name} +// +// +// ); +// }; + +// const ApiaryMenu = ({ apiary, selected, setSelected }) => { +// const theme = useTheme(); +// const colors = tokens(theme.palette.mode); + +// return ( +// {apiary.name}} +// > +// {apiary.devices.length > 0 ? ( +//
    +// {apiary.devices.map((device) => ( +// +// ))} +//
    +// ) : ( +// Please add a device +// )} +//
    +// ); +// }; + +const Sidebar = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + const [isCollapsed, setIsCollapsed] = useState(true); + const [selected, setSelected] = useState("Home"); + + // const { user } = useSelector((state) => state.auth); + // const { apiaries, isLoading, isError, message } = useSelector( + // (state) => state.apiary + // ); + + // useEffect(() => { + // if (isError) { + // console.log(message); + // } + + // if (!user) { + // navigate("/login"); + // } + + // dispatch(getApiaries()); + + // return () => { + // dispatch(apiaryReset()); + // //navigate("/"); + // }; + // }, [user, navigate, isError, message, dispatch]); + + if (!isCollapsed) { + theme.zIndex.drawer + 1, + }} + />; + } + + return ( + + + + {/* Header and Button */} + setIsCollapsed(!isCollapsed)} + icon={ + isCollapsed ? ( + + ) : undefined + } + style={{ + margin: "10px 0 20px 0", + color: "neutral.light", + }} + > + {!isCollapsed && ( + + Menu + + + + + )} + + + + + + + } + selected={selected} + setSelected={setSelected} + /> + {/* + Apiaries + + } + icon={ + + {" "} + + } + > + {apiaries.length > 0 ? ( +
    + {apiaries.map((apiary) => ( + + ))} +
    + ) : ( + + Please create an apiary + + )} +
    */} + } + selected={selected} + setSelected={setSelected} + /> + } + selected={selected} + setSelected={setSelected} + /> + } + selected={selected} + setSelected={setSelected} + /> +
    +
    +
    +
    +
    + ); +}; + +export default Sidebar; diff --git a/frontend/src/components/Topbar.jsx b/frontend/src/components/Topbar.jsx new file mode 100644 index 0000000..618d817 --- /dev/null +++ b/frontend/src/components/Topbar.jsx @@ -0,0 +1,125 @@ +import { Box, IconButton, useTheme, Typography, Avatar } from "@mui/material"; +import { useContext } from "react"; +import LightModeOutlinedIcon from "@mui/icons-material/LightModeOutlined"; +import DarkModeOutlinedIcon from "@mui/icons-material/DarkModeOutlined"; +import NotificationsOutlinedIcon from "@mui/icons-material/NotificationsOutlined"; +import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; +import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; +import { ColorModeContext, tokens } from "../theme"; +import { logout, reset } from "../features/auth/auth.slice.js"; +import { useNavigate } from "react-router-dom"; +import { useSelector, useDispatch } from "react-redux"; + +const Topbar = ({ title }) => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + const colorMode = useContext(ColorModeContext); + + const navigate = useNavigate(); + const dispatch = useDispatch(); + const { user } = useSelector((state) => state.auth); + + const onLogout = () => { + dispatch(logout()); + dispatch(reset()); + navigate("/login"); + }; + + return ( + + + + {title} + + + + + + {theme.palette.mode === "dark" ? ( + + + + ) : ( + + + + )} + + + + + + + + + + + + + + + + + + + ); +}; + +export default Topbar; diff --git a/frontend/src/components/UserCard.jsx b/frontend/src/components/UserCard.jsx new file mode 100644 index 0000000..c5b6175 --- /dev/null +++ b/frontend/src/components/UserCard.jsx @@ -0,0 +1,222 @@ +import { + Grid, + Avatar, + Card, + CardActions, + CardHeader, + Collapse, + IconButton, + Typography, + useTheme, + Button, + Box, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { useState } from "react"; +import { tokens } from "../theme"; +import { useDispatch } from "react-redux"; +import { updateMember, deleteMember } from "../features/apiary/apiary.slice"; + +const checkUser = async (apiary, user) => { + var role = "USER"; + apiary.members.forEach((member) => { + if (member.user._id === user._id) { + role = member.role; + return; + } + }); + return role; +}; + +const UserCard = ({ user, apiary }) => { + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [expand, setExpand] = useState(); + + const [formData, setFormData] = useState({ + isChecked: user.role !== "USER", + }); + + const { isChecked } = formData; + + const role = checkUser(apiary, user); + + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + isChecked: e.target.checked, + })); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + + const userData = { + role: isChecked ? "ADMIN" : "USER", + apiaryID: apiary._id, + userID: user.user._id, + }; + + dispatch(updateMember(userData)); + }; + + const onDelete = async (e) => { + e.preventDefault(); + + const userData = { + apiaryID: apiary._id, + userID: user.user._id, + }; + + dispatch(deleteMember(userData)); + }; + + return ( + + + {user.user.name.charAt(0).toUpperCase()} + + } + title={ {user.user.name} } + subheader={user.user.email} + action={ + + + setExpand(!expand)}> + + + + + } + /> + + + + {user.role === "CREATOR" ? ( + + Creator of this apiary + + ) : ( + <> + + } + label="Administrator" + onChange={onChange} + checked={isChecked} + fullWidth + disabled={user.role === "CREATOR"} + /> + + + + )} + + + {user.role === "CREATOR" ? ( + <> + ) : ( + <> + + + )} + + + + + ); +}; + +export default UserCard; diff --git a/frontend/src/custom.scss b/frontend/src/custom.scss new file mode 100644 index 0000000..c63db34 --- /dev/null +++ b/frontend/src/custom.scss @@ -0,0 +1,4 @@ +$submenu-bg-color: "primary.dark"; +$submenu-bg-color-collapsed: "primary.dark"; +$highlight-color: "secondary.light"; +@import "~react-pro-sidebar/dist/scss/styles.scss"; diff --git a/frontend/src/features/apiary/apiary.service.js b/frontend/src/features/apiary/apiary.service.js new file mode 100644 index 0000000..0e99fa9 --- /dev/null +++ b/frontend/src/features/apiary/apiary.service.js @@ -0,0 +1,223 @@ +import axios from "axios"; + +const API_URL = "/api/apiaries/"; + +// Get user's apiaries +const getApiaries = async (token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axios.get(API_URL, config); + + return response.data; +}; + +// Get user's apiaries +const getApiariesWithDeviceData = async (data, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axios.get(API_URL + "filter/" + data.filter, config); + + return response.data; +}; + +// Create new apiary +const setApiary = async (apiaryData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axios.post(API_URL, apiaryData, config); + + return response.data; +}; + +// Update apiary +const updateApiary = async (apiaryData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const { name, location } = apiaryData; + + const response = await axios.put( + API_URL + "apiary/" + apiaryData.apiaryID, + { name, location }, + config + ); + + return response.data; +}; + +// Delete user apiary +const deleteApiary = async (apiaryData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axios.delete( + API_URL + "apiary/" + apiaryData.apiaryID, + config + ); + + return response.data; +}; + +// Set device +const setDevice = async (apiaryData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axios.put( + API_URL + "apiary/" + apiaryData.apiaryID + "/setdevice", + apiaryData.deviceData, + config + ); + + return response.data; +}; + +// Update device +const updateDevice = async (apiaryData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const { serial, name, remote } = apiaryData; + + const response = await axios.put( + API_URL + + "apiary/" + + apiaryData.apiaryID + + "/device/" + + apiaryData.deviceID + + "/updatedevice", + { serial, name, remote }, + config + ); + + return response.data; +}; + +// Delete device +const deleteDevice = async (apiaryData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axios.put( + API_URL + + "apiary/" + + apiaryData.apiaryID + + "/device/" + + apiaryData.deviceID + + "/serial/" + + apiaryData.serial + + "/deletedevice", + {}, + config + ); + + return response.data; +}; + +// Set member +const setMember = async (userData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const { email, role } = userData; + + const response = await axios.put( + API_URL + "apiary/" + userData.apiaryID + "/setmember", + { email, role }, + config + ); + + return response.data; +}; + +// Update member +const updateMember = async (userData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const { role } = userData; + + const response = await axios.put( + API_URL + + "apiary/" + + userData.apiaryID + + "/user/" + + userData.userID + + "/updatemember", + { role }, + config + ); + + return response.data; +}; + +// Delete member +const deleteMember = async (userData, token) => { + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axios.put( + API_URL + + "apiary/" + + userData.apiaryID + + "/user/" + + userData.userID + + "/deletemember", + {}, + config + ); + + return response.data; +}; + +const apiaryService = { + setApiary, + getApiaries, + getApiariesWithDeviceData, + updateApiary, + deleteApiary, + setDevice, + updateDevice, + deleteDevice, + setMember, + updateMember, + deleteMember, +}; + +export default apiaryService; diff --git a/frontend/src/features/apiary/apiary.slice.js b/frontend/src/features/apiary/apiary.slice.js new file mode 100644 index 0000000..a01aafd --- /dev/null +++ b/frontend/src/features/apiary/apiary.slice.js @@ -0,0 +1,425 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import apiaryService from "./apiary.service.js"; + +const initialState = { + apiaries: [], + isError: false, + isSuccess: false, + isLoading: false, + message: "", +}; + +// Get user apiaries GOOD +export const getApiaries = createAsyncThunk( + "apiary/getApiaries", + async (_, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + if (!token) return; + return await apiaryService.getApiaries(token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Get user apiaries GOOD +export const getApiariesWithDeviceData = createAsyncThunk( + "apiary/getApiariesWithDeviceData", + async (data, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + if (!token) return; + return await apiaryService.getApiariesWithDeviceData(data, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Set new apiary GOOD +export const setApiary = createAsyncThunk( + "apiary/setApiary", + async (apiaryData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + if (!token) return; + return await apiaryService.setApiary(apiaryData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Update apiary GOOD +export const updateApiary = createAsyncThunk( + "apiary/updateApiary", + async (apiaryData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + return await apiaryService.updateApiary(apiaryData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Delete apiary GOOD +export const deleteApiary = createAsyncThunk( + "apiary/deleteApiary", + async (apiaryData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + if (!token) return; + return await apiaryService.deleteApiary(apiaryData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Set new device GOOD +export const setDevice = createAsyncThunk( + "apiary/setDevice", + async (apiaryData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + return await apiaryService.setDevice(apiaryData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Update device GOOD +export const updateDevice = createAsyncThunk( + "apiary/updateDevice", + async (apiaryData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + return await apiaryService.updateDevice(apiaryData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Delete device GOOD +export const deleteDevice = createAsyncThunk( + "apiary/deleteDevice", + async (apiaryData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + return await apiaryService.deleteDevice(apiaryData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Set new member GOOD +export const setMember = createAsyncThunk( + "apiary/setMember", + async (userData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + return await apiaryService.setMember(userData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Update member GOOD +export const updateMember = createAsyncThunk( + "apiary/updateMember", + async (userData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + return await apiaryService.updateMember(userData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +// Delete member GOOD +export const deleteMember = createAsyncThunk( + "apiary/deleteMember", + async (userData, thunkAPI) => { + if (!thunkAPI.getState().auth.user) return; + try { + const token = thunkAPI.getState().auth.user.token; + return await apiaryService.deleteMember(userData, token); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } + } +); + +export const apiarySlice = createSlice({ + name: "apiary", + initialState, + reducers: { + reset: (state) => initialState, + }, + extraReducers: (builder) => { + builder + .addCase(getApiaries.pending, (state) => { + state.isLoading = true; + }) + .addCase(getApiaries.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + state.apiaries = action.payload; + }) + .addCase(getApiaries.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(getApiariesWithDeviceData.pending, (state) => { + state.isLoading = true; + state.isSuccess = false; + }) + .addCase(getApiariesWithDeviceData.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + state.apiaries = action.payload; + }) + .addCase(getApiariesWithDeviceData.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(setApiary.pending, (state) => { + state.isLoading = true; + }) + .addCase(setApiary.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + state.apiaries.push(action.payload); + }) + .addCase(setApiary.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(updateApiary.pending, (state) => { + state.isLoading = true; + }) + .addCase(updateApiary.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + const index = state.apiaries.findIndex( + (apiary) => apiary._id === action.payload._id + ); + state.apiaries.fill(action.payload, index, index + 1); + }) + .addCase(updateApiary.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.apiaries = action.payload; + }) + + .addCase(deleteApiary.pending, (state) => { + state.isLoading = true; + }) + .addCase(deleteApiary.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + state.apiaries = state.apiaries.filter( + (apiary) => apiary._id !== action.payload._id + ); + }) + .addCase(deleteApiary.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(setDevice.pending, (state) => { + state.isLoading = true; + }) + .addCase(setDevice.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + const index = state.apiaries.findIndex( + (apiary) => apiary._id === action.payload._id + ); + state.apiaries.fill(action.payload, index, index + 1); + }) + .addCase(setDevice.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(updateDevice.pending, (state) => { + state.isLoading = true; + }) + .addCase(updateDevice.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + const index = state.apiaries.findIndex( + (apiary) => apiary._id === action.payload._id + ); + state.apiaries.fill(action.payload, index, index + 1); + }) + .addCase(updateDevice.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(deleteDevice.pending, (state) => { + state.isLoading = true; + }) + .addCase(deleteDevice.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + const index = state.apiaries.findIndex( + (apiary) => apiary._id === action.payload._id + ); + state.apiaries.fill(action.payload, index, index + 1); + }) + .addCase(deleteDevice.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(setMember.pending, (state) => { + state.isLoading = true; + }) + .addCase(setMember.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + const index = state.apiaries.findIndex( + (apiary) => apiary._id === action.payload._id + ); + state.apiaries.fill(action.payload, index, index + 1); + }) + .addCase(setMember.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(updateMember.pending, (state) => { + state.isLoading = true; + }) + .addCase(updateMember.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + const index = state.apiaries.findIndex( + (apiary) => apiary._id === action.payload._id + ); + state.apiaries.fill(action.payload, index, index + 1); + }) + .addCase(updateMember.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }) + + .addCase(deleteMember.pending, (state) => { + state.isLoading = true; + }) + .addCase(deleteMember.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + const index = state.apiaries.findIndex( + (apiary) => apiary._id === action.payload._id + ); + state.apiaries.fill(action.payload, index, index + 1); + }) + .addCase(deleteMember.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + }); + }, +}); + +export const { reset } = apiarySlice.actions; +export default apiarySlice.reducer; diff --git a/frontend/src/features/auth/auth.service.js b/frontend/src/features/auth/auth.service.js new file mode 100644 index 0000000..f272aac --- /dev/null +++ b/frontend/src/features/auth/auth.service.js @@ -0,0 +1,38 @@ +import axios from "axios"; + +const API_URL = "/api/users/"; + +// Register user +const register = async (userData) => { + const response = await axios.post(API_URL + "register", userData); + + if (response.data) { + localStorage.setItem("user", JSON.stringify(response.data)); + } + + return response.data; +}; + +// Login user +const login = async (userData) => { + const response = await axios.post(API_URL + "login", userData); + if (response.data && userData.isChecked) { + localStorage.setItem("user", JSON.stringify(response.data)); + localStorage.checkbox = userData.isChecked; + } + + return response.data; +}; + +// Logout user +const logout = () => { + localStorage.removeItem("user"); +}; + +const authService = { + register, + logout, + login, +}; + +export default authService; diff --git a/frontend/src/features/auth/auth.slice.js b/frontend/src/features/auth/auth.slice.js new file mode 100644 index 0000000..d68b6e4 --- /dev/null +++ b/frontend/src/features/auth/auth.slice.js @@ -0,0 +1,99 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import authService from "./auth.service.js"; + +// Get user from localStorage +const user = JSON.parse(localStorage.getItem("user")); + +const initialState = { + user: user ? user : null, + isError: false, + isSuccess: false, + isLoading: false, + message: "", +}; + +// Register user +export const register = createAsyncThunk( + "auth/register", + async (user, thunkAPI) => { + try { + return await authService.register(user); + } catch (error) { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + + return thunkAPI.rejectWithValue(message); + } + } +); + +// Login user +export const login = createAsyncThunk("auth/login", async (user, thunkAPI) => { + try { + return await authService.login(user); + } catch (error) { + const message = + (error.response && error.response.data && error.response.data.message) || + error.message || + error.toString(); + return thunkAPI.rejectWithValue(message); + } +}); + +export const logout = createAsyncThunk("auth/logout", async () => { + await authService.logout(); +}); + +export const authSlice = createSlice({ + name: "auth", + initialState, + reducers: { + reset: (state) => { + state.isLoading = false; + state.isSuccess = false; + state.isError = false; + state.message = ""; + }, + }, + extraReducers: (builder) => { + builder + .addCase(register.pending, (state) => { + state.isLoading = true; + }) + .addCase(register.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + state.user = action.payload; + }) + .addCase(register.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + state.user = null; + }) + .addCase(login.pending, (state) => { + state.isLoading = true; + }) + .addCase(login.fulfilled, (state, action) => { + state.isLoading = false; + state.isSuccess = true; + state.user = action.payload; + }) + .addCase(login.rejected, (state, action) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; + state.user = null; + }) + .addCase(logout.fulfilled, (state) => { + state.user = null; + }); + }, +}); + +export const { reset } = authSlice.actions; +export default authSlice.reducer; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..da153e2 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,40 @@ +@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap"); + +html, +body, +#root, +.app, +.content { + height: 100%; + width: 100%; + font-family: "Poppins", sans-serif; +} + +.app { + display: flex; + position: relative; +} + +::-webkit-scrollbar { + width: 10px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: #e0e0e0; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #888; +} + +/* Handle on Hover */ +::-webkit-scrollbar-track:hover { + background: #555; +} + +fieldset { + border: none !important; + outline: none !important; +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..522650a --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,23 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; +import { store } from "./app/store"; +import App from "./App"; +import reportWebVitals from "./reportWebVitals"; +import "./index.css"; + +const container = document.getElementById("root"); +const root = createRoot(container); + +root.render( + // + + + + // +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/frontend/src/pages/About.jsx b/frontend/src/pages/About.jsx new file mode 100644 index 0000000..4c271b5 --- /dev/null +++ b/frontend/src/pages/About.jsx @@ -0,0 +1,83 @@ +import { Box, Grid, Typography, Avatar, useTheme } from "@mui/material"; +import { tokens } from "../theme"; +import { useDispatch, useSelector } from "react-redux"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import AboutCard from "../components/AboutCard"; +import { Grow, Fade } from "@mui/material"; +import Backdrop from "@mui/material/Backdrop"; + +const Manage = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const { user } = useSelector((state) => state.auth); + + const stmts = [ + ["Our Mission Statement", "Our motivation for this project is to ensure that bees are protected as their role to the environment is of utmost importance to the sustainability of our ecosystems. Our key principle for this project is to help beekeepers maintain their role as caretakers for the bees and hive by providing solutions that can be easily implemented and used in their field."], + ["Our Commitment", "As students of the university, we are committed to providing an uncompromising standard of support to this project through learning, creativity, and development of skills in order to ensure the utmost quality of work is provided."], + ["Santa Clara Mission Statement", "The University pursues its vision by creating an academic community that educates the whole person within the Jesuit, Catholic tradition, making student learning our central focus, continuously improving our curriculum and co-curriculum, strengthening our scholarship and creative work, and serving the communities of which we are a part in Silicon Valley and around the world."], + ["More Information", "Github: https://github.com/paizcollin/beehive-webapp Research Document: https://drive.google.com/file/d/1Ble9ErteABF_nTk1ISFajrWrM6_Eq5KN/view?usp=sharing"], + ["Special Thanks to", "Gerhard and Lisa Eschelbeck, Wendy Mather, and Kian Nikzad"], + ]; + + useEffect(() => { + if (!user) { + navigate("/login"); + } + }, [user, navigate]); + + return ( + + + + + + + + About + + <> +
    + {stmts.map((stmt) => ( + + ))} +
    + +
    +
    +
    + ); +}; + +export default Manage; diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..016264c --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,154 @@ +import { Box, Typography, useTheme, Grid } from "@mui/material"; +import { tokens } from "../theme"; +import { useDispatch, useSelector } from "react-redux"; +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + getApiariesWithDeviceData, + reset as apiaryReset, +} from "../features/apiary/apiary.slice"; +import { toast } from "react-toastify"; +import Graph from "../components/Graph"; +import Overview from "../components/Overview"; +import SelectApiary from "../components/SelectApiary"; + +const Dashboard = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const { user } = useSelector((state) => state.auth); + const { apiaries, isLoading, isError, message } = useSelector( + (state) => state.apiary + ); + + const filterOptions = [ + { label: "1 Day", code: "1day", value: 1 }, + { label: "1 Week", code: "1week", value: 7 }, + { label: "1 Month", code: "1month", value: 30 }, + { label: "3 Months", code: "3month", value: 90 }, + { label: "6 Months", code: "6month", value: 180 }, + { label: "1 Year", code: "1year", value: 365 }, + { label: "2 Year", code: "2year", value: 730 }, + { label: "all", value: Infinity }, + ]; + + const [apiary, setApiary] = useState(""); + const [device, setDevice] = useState(""); + const [ovDevice, setOvDevice] = useState(""); + const [selectedFilter, setSelectedFilter] = useState({ + label: "initial", + code: "init", + value: 0, + }); + + useEffect(() => { + if (!user) { + navigate("/login"); + } + + dispatch(getApiariesWithDeviceData({ filter: selectedFilter.code })); + + return () => { + dispatch(apiaryReset()); + }; + }, [user, navigate, dispatch, selectedFilter]); + + useEffect(() => { + if (apiaries.length > 0 && apiary && device) { + const selectedDevice = apiaries + .find((a) => a.name === apiary.name) + .devices.find((d) => d.name === device.name); + if (selectedDevice) { + setDevice(selectedDevice); + } + } + if (selectedFilter.code === "1day") { + setOvDevice(device); + } + }, [apiaries, apiary, device, selectedFilter.code]); + + useEffect(() => { + if (isError) { + toast.error(message, { + toastID: "dashboardError", + hideProgressBar: true, + theme: "colored", + }); + } + }, [isError, message]); + + return ( + <> + + + + + + + + {device.name} + + + + + + + + + + + + + ); +}; + +export default Dashboard; diff --git a/frontend/src/pages/FAQ.jsx b/frontend/src/pages/FAQ.jsx new file mode 100644 index 0000000..0917aff --- /dev/null +++ b/frontend/src/pages/FAQ.jsx @@ -0,0 +1,117 @@ +import { Box, Grid, Typography, Avatar, useTheme } from "@mui/material"; +import HiveOutlinedIcon from "@mui/icons-material/HiveOutlined"; +import { tokens } from "../theme"; +import Loading from "../components/Loading"; +import { useDispatch, useSelector } from "react-redux"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import HelpOutlineOutlinedIcon from "@mui/icons-material/HelpOutlineOutlined"; +import FAQCard from "../components/FAQCard"; +import { toast } from "react-toastify"; +import { Grow, Fade } from "@mui/material"; +import Backdrop from "@mui/material/Backdrop"; + +const Manage = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const { user } = useSelector((state) => state.auth); + + const faqs = [ + [ + 'What is an "apiary"?', + "An apiary is a collection of devices that reflect the structure of your own apiary and hives. Apiaries are given a name, a location, a set of device(s) (one for each hive at your apiary), and up to 10 members to interact with the apiary.", + ], + [ + "What is a hive?", + "A hive is structure that houses a colony of bees within a box to protect the bees from the elements and keep them thriving to produce honey, beeswax, pollen, and royal jelly.", + ], + [ + "How do I set up and use the hardware?", + "You can set up the hardware by using a Raspberry Pi 4 Model B and a Raspberry Pi Camera Module V2 to mount on the bee hive.", + ], + [ + "How do I set up and use the software?", + "You can set up the software by installing the machine learning model on the device itself which will then take the input from the camera and output it to the dashboard which will be hosted remotely on a server. You can then access this dashboard from a website which is where this page is hosted.", + ], + [ + "Do I have to pay a subscription to use the software?", + "No, you do not need to pay a subscription fee to use the software because it is all open source and available for anyone to use.", + ], + [ + "Will there be continued support for this project?", + "Yes, there will be continued support for this project as future Santa Clara University students contribute to this project.", + ], + [ + "How is this different to the alternatives already on the market?", + "This is different from other alternatives because our software is open source which can be utilized by anyone. Also the software can be improved by anyone's contributions. This means that the software is entirely free and features can be added very easily.", + ], + [ + "Where can I find the required hardware to use this service?", + "You can purchase the required hardware from any retailer that sells Raspberry Pi's such as Amazon or the Raspberry Pi website itself.", + ], + [ + "How do I mount the device?", + "Due to the open source and configurable nature of the device, it can be mounted to the hive however the beekeeper wishes to. Idealy, the beekeeper should have the device fastened permanently with some enclosure surrounding the device such as a plexiglass box to protect it from the elements.", + ], + ]; + + useEffect(() => { + if (!user) { + navigate("/login"); + } + }, [user, navigate]); + + return ( + + + + + + + + FAQ + + <> +
    + {faqs.map((faq) => ( + + ))} +
    + +
    +
    +
    + ); +}; + +export default Manage; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..3062cd5 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,227 @@ +import { useState, useEffect, setState } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import { login, reset } from "../features/auth/auth.slice.js"; +import Loading from "../components/Loading"; +import Avatar from "@mui/material/Avatar"; +import Button from "@mui/material/Button"; +import CssBaseline from "@mui/material/CssBaseline"; +import TextField from "@mui/material/TextField"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Checkbox from "@mui/material/Checkbox"; +import Link from "@mui/material/Link"; +import Grid from "@mui/material/Grid"; +import Box from "@mui/material/Box"; +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import Typography from "@mui/material/Typography"; +import Container from "@mui/material/Container"; +import { useTheme, ThemeProvider } from "@mui/material/styles"; +import { tokens } from "../theme.js"; +import { Grow, Fade } from "@mui/material"; +import Backdrop from "@mui/material/Backdrop"; + +const Login = () => { + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [trans, setTrans] = useState(true); + + const handleTrans = () => { + setTrans((prev) => !prev); + }; + + const [formData, setFormData] = useState({ + email: "", + password: "", + isChecked: true, + }); + + const { email, password, isChecked } = formData; + + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const { user, isLoading, isError, isSuccess, message } = useSelector( + (state) => state.auth + ); + + useEffect(() => { + if (isError) { + toast.error(message, { + toastID: "loginError", + hideProgressBar: true, + theme: "colored", + }); + } + + if (isSuccess || user) { + navigate("/"); + } + + dispatch(reset()); + }, [user, isError, isSuccess, message, navigate, dispatch]); + + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + isChecked: e.target.checked, + })); + }; + + const onSubmit = (e) => { + e.preventDefault(); + + const userData = { + email, + password, + isChecked, + }; + + dispatch(login(userData)); + }; + + if (isLoading) { + return ( + + theme.zIndex.drawer + 1, + }} + > + + + + ); + } + + return ( + <> + + + + + + + + + + Sign in + + + + + + } + label="Remember me" + onChange={onChange} + checked={isChecked} + /> + + + + + Forgot password? + + + + + {"Don't have an account? Sign up"} + + + + + + + + + + ); +}; + +export default Login; diff --git a/frontend/src/pages/Manage.jsx b/frontend/src/pages/Manage.jsx new file mode 100644 index 0000000..abf1482 --- /dev/null +++ b/frontend/src/pages/Manage.jsx @@ -0,0 +1,136 @@ +import { + Box, + Grid, + Typography, + Avatar, + useTheme, + createTheme, + FormControl, + Select, + MenuItem, +} from "@mui/material"; +import HiveOutlinedIcon from "@mui/icons-material/HiveOutlined"; +import GroupOutlinedIcon from "@mui/icons-material/GroupOutlined"; +import DeviceHubOutlinedIcon from "@mui/icons-material/DeviceHubOutlined"; +import { tokens } from "../theme"; +import Loading from "../components/Loading"; +import { useDispatch, useSelector } from "react-redux"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + getApiaries, + getApiariesWithDeviceData, + reset as apiaryReset, +} from "../features/apiary/apiary.slice"; +import UserCard from "../components/UserCard"; +import DeviceCard from "../components/DeviceCard"; +import ApiaryCard from "../components/ApiaryCard"; +import AddApiaryCard from "../components/AddApiaryCard"; +import { toast } from "react-toastify"; +import { Grow, Fade } from "@mui/material"; +import Backdrop from "@mui/material/Backdrop"; + +const Manage = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const { user } = useSelector((state) => state.auth); + const { apiaries, isLoading, isError, message } = useSelector( + (state) => state.apiary + ); + + useEffect(() => { + if (isError) { + toast.error(message, { + toastID: "manageError", + hideProgressBar: true, + theme: "colored", + }); + } + + if (!user) { + navigate("/login"); + } + + dispatch(getApiaries()); + + return () => { + dispatch(apiaryReset()); + }; + }, [user, navigate, isError, message, dispatch]); + + if (isLoading) { + return ( + + theme.zIndex.drawer + 1, + }} + > + + + + ); + } + + return ( + + + + + + + + Manage Apiaries + + <> + {apiaries.length > 0 ? ( +
    + {apiaries.map((apiary) => ( + + ))} +
    + ) : ( + + Please create an apiary + + )} + + +
    +
    +
    + ); +}; + +export default Manage; diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..6d696b2 --- /dev/null +++ b/frontend/src/pages/Register.jsx @@ -0,0 +1,255 @@ +import { useState, useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { register, reset } from "../features/auth/auth.slice.js"; +import Loading from "../components/Loading"; +import Avatar from "@mui/material/Avatar"; +import Button from "@mui/material/Button"; +import CssBaseline from "@mui/material/CssBaseline"; +import TextField from "@mui/material/TextField"; +import Link from "@mui/material/Link"; +import Grid from "@mui/material/Grid"; +import Box from "@mui/material/Box"; +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import Typography from "@mui/material/Typography"; +import Container from "@mui/material/Container"; +import { useTheme, ThemeProvider } from "@mui/material/styles"; +import { tokens } from "../theme.js"; +import { toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { Grow, Fade } from "@mui/material"; +import Backdrop from "@mui/material/Backdrop"; +import FilledInput from "@mui/material/FilledInput"; + +const Register = () => { + // mui themes + const theme = useTheme(); + const colors = tokens(theme.palette.mode); + + const [trans, setTrans] = useState(true); + + const handleTrans = () => { + setTrans((prev) => !prev); + }; + + // form data state + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + password2: "", + }); + + // get form data from state + const { name, email, password, password2 } = formData; + + // nav, dispatch + const navigate = useNavigate(); + const dispatch = useDispatch(); + + // get state of auth + const { user, isLoading, isError, isSuccess, message } = useSelector( + (state) => state.auth + ); + + useEffect(() => { + if (isError) { + toast.error(message, { + toastID: "registerError", + hideProgressBar: true, + theme: "colored", + }); + } + + if (isSuccess || user) { + handleTrans(); + navigate("/"); + } + + dispatch(reset()); + }, [user, isError, isSuccess, message, navigate, dispatch]); + + // handle form changes + const onChange = (e) => { + setFormData((prevState) => ({ + ...prevState, + [e.target.name]: e.target.value, + })); + }; + + // handle form submission + const onSubmit = (e) => { + e.preventDefault(); + + if (password.length < 8) { + toast.error("Password must be at least 8 characters", { + toastID: "lengthError", + hideProgressBar: true, + theme: "colored", + }); + } else if (password !== password2) { + toast.error("Passwords do not match", { + toastID: "matchError", + hideProgressBar: true, + theme: "colored", + }); + } else { + const userData = { + name, + email, + password, + }; + + dispatch(register(userData)); + } + }; + + // if loading -> spin + if (isLoading) { + return ( + + theme.zIndex.drawer + 1, + }} + > + + + + ); + } + + return ( + <> + + + + + + + + + + Sign Up + + + + + + + + + + + {"Already have an account? Sign in"} + + + + + + + + + + ); +}; + +export default Register; diff --git a/frontend/src/reportWebVitals.js b/frontend/src/reportWebVitals.js new file mode 100644 index 0000000..5253d3a --- /dev/null +++ b/frontend/src/reportWebVitals.js @@ -0,0 +1,13 @@ +const reportWebVitals = onPerfEntry => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 0000000..74b1a27 --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom/extend-expect'; diff --git a/frontend/src/theme.js b/frontend/src/theme.js new file mode 100644 index 0000000..fb0f8d2 --- /dev/null +++ b/frontend/src/theme.js @@ -0,0 +1,389 @@ +import { createContext, useState, useMemo } from "react"; +import { createTheme } from "@mui/material/styles"; + +// color design tokens +export const tokens = (mode) => ({ + ...(mode === "dark" + ? { + grey: { + 100: "#e0e0e0", + 200: "#c2c2c2", + 300: "#a3a3a3", + 400: "#858585", + 500: "#666666", + 600: "#525252", + 700: "#3d3d3d", + 800: "#292929", + 900: "#141414", + }, + primary: { + 100: "#dbdbdf", + 200: "#b6b7bf", + 300: "#92939e", + 400: "#6d6f7e", + 500: "#494b5e", + 600: "#3a3c4b", + 700: "#2c2d38", + 800: "#1d1e26", + 900: "#0f0f13", + }, + yellowAccent: { + 100: "#faf6d8", + 200: "#f5edb1", + 300: "#f0e48a", + 400: "#ebdb63", + 500: "#e6d23c", + 600: "#b8a830", + 700: "#8a7e24", + 800: "#5c5418", + 900: "#2e2a0c", + }, + orangeAccent: { + 100: "#fae4d8", + 200: "#f5c9b1", + 300: "#f0ae8a", + 400: "#eb9363", + 500: "#e6783c", + 600: "#b86030", + 700: "#8a4824", + 800: "#5c3018", + 900: "#2e180c", + }, + pinkAccent: { + 100: "#eed8fa", + 200: "#ddb1f5", + 300: "#cd8af0", + 400: "#bc63eb", + 500: "#ab3ce6", + 600: "#8930b8", + 700: "#67248a", + 800: "#44185c", + 900: "#220c2e", + }, + tealAccent: { + 100: "#d8eefa", + 200: "#b1dcf5", + 300: "#8acbf0", + 400: "#63b9eb", + 500: "#3ca8e6", + 600: "#3086b8", + 700: "#24658a", + 800: "#18435c", + 900: "#0c222e", + }, + greenAccent: { + 100: "#d8fada", + 200: "#b1f5b4", + 300: "#8af08f", + 400: "#63eb69", + 500: "#3ce644", + 600: "#30b836", + 700: "#248a29", + 800: "#185c1b", + 900: "#0c2e0e", + }, + } + : { + grey: { + 100: "#141414", + 200: "#292929", + 300: "#3d3d3d", + 400: "#525252", + 500: "#666666", + 600: "#858585", + 700: "#a3a3a3", + 800: "#c2c2c2", + 900: "#e0e0e0", + }, + primary: { + 100: "#0f0f13", + 200: "#1d1e26", + 300: "#2c2d38", + 400: "#3a3c4b", + 500: "#494b5e", + 600: "#6d6f7e", + 700: "#92939e", + 800: "#b6b7bf", + 900: "#dbdbdf", + }, + yellowAccent: { + 100: "#2e2a0c", + 200: "#5c5418", + 300: "#8a7e24", + 400: "#b8a830", + 500: "#e6d23c", + 600: "#ebdb63", + 700: "#f0e48a", + 800: "#f5edb1", + 900: "#faf6d8", + }, + orangeAccent: { + 100: "#2e180c", + 200: "#5c3018", + 300: "#8a4824", + 400: "#b86030", + 500: "#e6783c", + 600: "#eb9363", + 700: "#f0ae8a", + 800: "#f5c9b1", + 900: "#fae4d8", + }, + pinkAccent: { + 100: "#220c2e", + 200: "#44185c", + 300: "#67248a", + 400: "#8930b8", + 500: "#ab3ce6", + 600: "#bc63eb", + 700: "#cd8af0", + 800: "#ddb1f5", + 900: "#eed8fa", + }, + tealAccent: { + 100: "#0c222e", + 200: "#18435c", + 300: "#24658a", + 400: "#3086b8", + 500: "#3ca8e6", + 600: "#63b9eb", + 700: "#8acbf0", + 800: "#b1dcf5", + 900: "#d8eefa", + }, + greenAccent: { + 100: "#0c2e0e", + 200: "#185c1b", + 300: "#248a29", + 400: "#30b836", + 500: "#3ce644", + 600: "#63eb69", + 700: "#8af08f", + 800: "#b1f5b4", + 900: "#d8fada", + }, + }), +}); + +// mui theme settings +export const themeSettings = (mode) => { + const colors = tokens(mode); + + return { + palette: { + mode: mode, + ...(mode === "dark" + ? { + primary: { + main: colors.primary[500], + light: colors.primary[100], + dark: colors.primary[700], + darker: colors.primary[800], + }, + secondary: { + main: colors.yellowAccent[500], + light: colors.yellowAccent[300], + dark: colors.yellowAccent[700], + }, + onSecondary: { + main: colors.grey[700], + }, + selected: { + main: colors.yellowAccent[300], + light: colors.yellowAccent[100], + }, + neutral: { + dark: colors.grey[700], + main: colors.grey[500], + light: colors.grey[100], + }, + background: { + default: colors.primary[800], + }, + err: { + main: "#e74c3c", + }, + } + : { + primary: { + main: colors.primary[500], + light: colors.primary[100], + dark: "#eeeeee", + darker: "#aeaeae", + }, + secondary: { + main: colors.yellowAccent[500], + light: colors.yellowAccent[300], + dark: colors.yellowAccent[700], + }, + onSecondary: { + main: colors.grey[100], + }, + selected: { + main: colors.yellowAccent[300], + light: colors.yellowAccent[100], + }, + neutral: { + dark: colors.grey[700], + main: colors.grey[500], + light: colors.grey[100], + }, + background: { + default: colors.primary[900], + }, + err: { + main: "#e74c3c", + }, + }), + }, + link: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 12, + h1: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 40, + fontWeight: 600, + }, + h2: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 32, + fontWeight: 500, + }, + h3: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 24, + }, + h4: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 20, + }, + h5: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 16, + }, + h6: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 14, + }, + }, + typography: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 12, + h1: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 40, + fontWeight: 600, + }, + h2: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 32, + fontWeight: 500, + }, + h3: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 24, + }, + h4: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 20, + }, + h5: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 16, + }, + h6: { + fontFamily: ["Poppins", "sans-serif"].join(","), + fontSize: 14, + }, + }, + components: { + MuiInputLabel: { + styleOverrides: { + root: { + "&.Mui-focused": { + color: colors.primary[100], + }, + }, + }, + }, + MuiFilledInput: { + styleOverrides: { + root: { + borderRadius: `6px`, + backgroundColor: "primary.main", + border: "transparent", + color: colors.primary[100], + "&.Mui-focused": { + color: colors.primary[100], + }, + }, + label: { + "&.Mui-focused": { + color: "yellow", + }, + }, + underline: { + "&:before": { + borderBottomColor: "transparent", + }, + "&:after": { + borderBottomColor: "transparent", + }, + "&:hover:not(.Mui-disabled)::before": { + borderBottomColor: "transparent", + }, + "&:hover:not(.Mui-disabled)::after": { + borderBottomColor: "transparent", + }, + }, + }, + }, + }, + transitions: { + duration: { + shortest: 150, + shorter: 200, + short: 250, + // most basic recommended timing + standard: 300, + // this is to be used in complex animations + complex: 375, + // recommended when something is entering screen + enteringScreen: 225, + // recommended when something is leaving screen + leavingScreen: 195, + }, + easing: { + // This is the most common easing curve. + easeInOut: "cubic-bezier(0.4, 0, 0.2, 1)", + // Objects enter the screen at full velocity from off-screen and + // slowly decelerate to a resting point. + easeOut: "cubic-bezier(0.0, 0, 0.2, 1)", + // Objects leave the screen at full velocity. They do not decelerate when off-screen. + easeIn: "cubic-bezier(0.4, 0, 1, 1)", + // The sharp curve is used by objects that may return to the screen at any time. + sharp: "cubic-bezier(0.4, 0, 0.6, 1)", + }, + }, + }; +}; + +// context for color mode +export const ColorModeContext = createContext({ + toggleColorMode: () => {}, +}); + +export const useMode = () => { + const [mode, setMode] = useState("dark"); + + const colorMode = useMemo( + () => ({ + toggleColorMode: () => + setMode((prev) => (prev === "light" ? "dark" : "light")), + }), + [] + ); + + const theme = useMemo(() => createTheme(themeSettings(mode)), [mode]); + + return [theme, colorMode]; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..fbde890 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "beehive-webapp", + "version": "1.0.0", + "description": "dashboard web application for beehive monitor system", + "main": "server.js", + "scripts": { + "start": "node backend/server.js", + "server": "nodemon backend/server.js", + "client": "npm start --prefix frontend", + "dev": "concurrently \"npm run server\" \"npm run client\"", + "frontend": "npm install --prefix frontend", + "yayaya": "concurrently \"npm install\" \"npm run frontend\"", + "preinstall": "npx npm-force-resolutions", + "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix frontend --legacy-peer-deps && npm run build --prefix frontend --legacy-peer-deps" + }, + "author": "Collin Paiz", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "colors": "^1.4.0", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "express-async-handler": "^1.2.0", + "jsonwebtoken": "^9.0.0", + "mongoose": "^6.9.2", + "react-stickynode": "^4.1.0" + }, + "devDependencies": { + "concurrently": "^7.6.0", + "nodemon": "^2.0.20" + } +}