';
+```
+
+## API reference
+
+All endpoints are JSON. Auth-required endpoints expect the `jwtCookie` httpOnly cookie.
+
+| Method | Path | Auth | Description |
+| ------ | ----------------------------- | ------ | ------------------------------ |
+| GET | `/api/health` | — | Liveness check |
+| POST | `/api/auth/register` | — | Create account |
+| POST | `/api/auth/login` | — | Sign in |
+| POST | `/api/auth/logout` | — | Clear auth cookie |
+| GET | `/api/auth/me` | user | Current session |
+| GET | `/api/issues` | — | List all issues |
+| POST | `/api/issues` | user | Report new issue |
+| GET | `/api/issues/:id` | user | Issue detail |
+| PUT | `/api/issues/:id` | owner | Update own issue |
+| DELETE | `/api/issues/:id` | owner | Delete own issue |
+| GET | `/api/solution/:issue_id` | user | Current solution for issue |
+| POST | `/api/solution/:issue_id` | admin | Post solution |
+
+Error shape (all endpoints): `{ "error": "message", "code"?: "SLUG", "details"?: … }`.
+
+## Scripts
+
+Backend (root `package.json`):
+
+```bash
+npm run server # nodemon dev server
+npm start # production server
+```
+
+Frontend (`frontend/package.json`):
+
+```bash
+npm run dev # Vite dev server with HMR
+npm run build # production bundle into dist/
+npm run preview # preview production bundle
+npm run lint # ESLint
+```
+
+## Contributing
+
+Read [`CONTRIBUTING.md`](./CONTRIBUTING.md). In short: fork, branch, run the checks,
+open a PR that respects the constitution.
+
+## License
+
+ISC.
diff --git a/backend/controllers/authControllers.js b/backend/controllers/authControllers.js
deleted file mode 100644
index ce04340..0000000
--- a/backend/controllers/authControllers.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import bcrypt from "bcrypt";
-import pool from "../database/db.js";
-import generateJWTandSetCookie from "../utils/jwt.js";
-const register = async (req, res) => {
- try{
- const { name, admission_number, password, confirmPassword, gender} = req.body;
- if(password !== confirmPassword) {
- return res.status(400).json({error: "Password do not match"});
- }
- const { rows } = await pool.query(`SELECT * FROM users WHERE admission_number=$1`, [admission_number]);
- if(rows.length > 0){
- return res.status(401).json({error: "User already exists"});
- }
- const saltRounds = 10;
- bcrypt.hash(password, saltRounds, async (err, hashedPassword) => {
- if(err){
- console.log("Error Hashing Password ", err);
- } else{
- const profilePic = gender==="male" ? `https://avatar.iran.liara.run/public/boy` : `https://avatar.iran.liara.run/public/girl `
- const query = "INSERT INTO users (name, admission_number, password, gender, profilePic, role) VALUES ($1, $2, $3, $4, $5, DEFAULT) RETURNING *";
- const values = [name, admission_number, hashedPassword, gender, profilePic];
- try{
- const { rows } = await pool.query(query, values);
- generateJWTandSetCookie(rows[0].id, res);
- return res.status(201).json({
- id: rows[0].id,
- name: rows[0].name,
- admission_number: rows[0].admission_number,
- gender: rows[0].gender,
- profilePic: rows[0].profilePic,
- })
- } catch(error){
- console.log(error);
- return res.status(500).json({error: "Database error"});
- }
- }
- })
- } catch(error){
- console.log("Error in register controller");
- return res.status(500).json({error: "server side error in register"});
- }
-}
-
-const login = async (req, res) => {
- try{
- const { admission_number, password } = req.body;
- const { rows } = await pool.query(`SELECT * FROM users WHERE admission_number=$1`, [admission_number]);
- if (!rows.length || !(await bcrypt.compare(password, rows[0].password))) {
- return res.status(400).json({ error: "Invalid admission number or password" });
- }
- generateJWTandSetCookie(rows[0].id, res);
- return res.status(201).json({
- id: rows[0].id,
- name: rows[0].name,
- admission_number: rows[0].admission_number,
- gender: rows[0].gender,
- profilePic: rows[0].profilePic,
- role: rows[0].role
- })
- } catch(error){
- console.log("Error in login controller", error);
- return res.status(500).json({error: "server side error in login"});
- }
-}
-
-const logout = async (req, res) => {
- try{
- await res.clearCookie("jwtCookie");
- return res.status(200).json({ message: "Logged out successfully"});
- } catch(err){
- console.log("Error in logout controller", err);
- return res.status(500).json({error: "Internal Server Error"});
- }
-}
-
-export { register, login, logout };
\ No newline at end of file
diff --git a/backend/controllers/issuesControllers.js b/backend/controllers/issuesControllers.js
deleted file mode 100644
index dc2b098..0000000
--- a/backend/controllers/issuesControllers.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import pool from "../database/db.js";
-
-const getAllIssues = async (req, res) => {
- try{
- // const query = "SELECT * FROM issues ORDER BY created_at DESC";
- const query =
- `SELECT issues.*,
- users.name AS created_by
- FROM issues
- LEFT JOIN users ON issues.user_id = users.id
- ORDER BY created_at DESC`;
- const { rows } = await pool.query(query);
- return res.status(200).json(rows);
- } catch(error){
- console.log("Error in issue Controller", error);
- return res.status(500).json({error: "Server side error in fetching issues"});
- }
-}
-
-const reportIssue = async (req, res) => {
- try{
- const {title, description, user_id} = req.body;
- const query = "INSERT INTO issues (title, description, user_id) VALUES ($1, $2, $3) RETURNING *";
- const values = [title, description, user_id]
- const { rows } = await pool.query(query, values);
- return res.status(201).json(rows[0]);
- } catch(error){
- console.log("Error in issue Controller", error.message);
- return res.status(500).json({error: "Server side error in reporting issues"});
- }
-}
-
-const getIssue = async (req, res) => {
- try{
- const {id : issues_id } = req.params;
- // const query = `SELECT * FROM issues WHERE id = $1`;
- const query = `
- SELECT
- issues.*,
- users.name AS created_by
- FROM issues
- LEFT JOIN users ON issues.user_id = users.id
- WHERE issues.id = $1
- `;;
- const { rows } = await pool.query(query, [issues_id]);
- if(rows.length === 0){
- return res.status(400).json({ error: "Invalid issue ID" });
- }
- return res.status(200).json(rows[0]);
- } catch (error){
- console.log("Error in issueController", error.message);
- return res.status(500).json({error: "Server side error"});
- }
-}
-
-const updateIssue = async (req, res) => {
- try{
- const { id: issue_id } = req.params;
- const {title, description } = req.body;
-
- const checkQuery = "SELECT * FROM issues WHERE id = $1";
- const { rows: existingIssue } = await pool.query(checkQuery, [issue_id]);
- if (existingIssue.length === 0) {
- return res.status(404).json({ error: "Issue not found" });
- }
-
- //Check if user is authorised to update or not
- const user_id = req.user.id;
- if(existingIssue[0].user_id !== user_id){
- return res.status(404).json({ error: "Invalid user" });
- }
-
- const updateQuery = ` UPDATE issues SET title = $1, description = $2, updated_at = NOW() WHERE id = $3 RETURNING *`;
- const values = [title, description, issue_id];
- const { rows } = await pool.query(updateQuery, values);
- return res.status(201).json(rows[0]);
- } catch(error){
- console.log("Error in issues controller", error);
- return res.status(500).json({error: "Servers side error"});
- }
-}
-
-const deleteIssue = async (req, res) => {
- try{
- const { id: issue_id } = req.params;
- const checkQuery = "SELECT * FROM issues WHERE id = $1";
- const { rows: existingIssue } = await pool.query(checkQuery, [issue_id]);
- if (existingIssue.length === 0) {
- return res.status(404).json({ error: "Issue does not exist" });
- }
-
- const user_id = req.user.id;
- if(existingIssue[0].user_id !== user_id){
- return res.status(404).json({ error: "Invalid user" });
- }
-
- const deleteQuery = "DELETE FROM issues WHERE id=$1";
- await pool.query(deleteQuery, [issue_id]);
- return res.status(200).json({ message: "Issue deleted successfully" });
- } catch (error){
- console.log("Error in issues controller", error);
- return res.status(500).json({error: "Servers side error"});
- }
-}
-export {getAllIssues, reportIssue, getIssue, updateIssue, deleteIssue};
\ No newline at end of file
diff --git a/backend/controllers/solutionControllers.js b/backend/controllers/solutionControllers.js
deleted file mode 100644
index 884edee..0000000
--- a/backend/controllers/solutionControllers.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import pool from "../database/db.js";
-
-const createSolution = async (req, res) => {
- try{
- if(req.user.role !== "admin"){
- return res.status(400).json({ message: "Access Denied!"});
- }
-
- const { issue_id } = req.params;
- const { description } = req.body;
- const createSolutionQuery = "INSERT INTO solutions (issue_id, user_id, description) VALUES ($1, $2, $3) RETURNING *";
- const { rows } = await pool.query(createSolutionQuery, [issue_id, req.user.id, description]);
- return res.status(201).json(rows[0]);
- } catch(error){
- console.log("Error in solution controller", error);
- return res.status(500).json({error: "Server side error"});
- }
-}
-
-const getSolution = async (req, res) => {
- try{
- const { issue_id } = req.params;
- const getSolutionQuery = "SELECT * FROM solutions WHERE issue_id=$1";
- const { rows } = await pool.query(getSolutionQuery, [issue_id]);
- if(rows.length === 0){
- return res.json({description: null, created_at: null});
- }
- return res.status(201).json(rows[0]);
- } catch(error){
- console.log("Error in solution controller", error);
- return res.status(500).json({error: "Server side error"});
- }
-}
-
-
-export { createSolution, getSolution}
\ No newline at end of file
diff --git a/backend/database/db.js b/backend/database/db.js
deleted file mode 100644
index 7d08956..0000000
--- a/backend/database/db.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import pg from "pg";
-import env from "dotenv";
-env.config();
-
-const pool = new pg.Pool({
- user: process.env.DB_USER,
- host: process.env.DB_HOST,
- database: process.env.DATABASE,
- password: process.env.DB_PASSWORD,
- port: process.env.DB_PORT
-});
-
-const createTables = async () => {
- const createUsersTable = `
- CREATE TABLE IF NOT EXISTS users (
- id SERIAL PRIMARY KEY,
- name VARCHAR(255) NOT NULL,
- admission_number VARCHAR(255) UNIQUE NOT NULL,
- password VARCHAR(255) NOT NULL,
- gender VARCHAR(50) NOT NULL,
- profilePic VARCHAR(255) NOT NULL,
- role VARCHAR(50) DEFAULT 'user',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- `;
-
- const createIssuesTable = `
- CREATE TABLE IF NOT EXISTS issues (
- id SERIAL PRIMARY KEY,
- title VARCHAR(255) NOT NULL,
- description TEXT,
- status VARCHAR(50) DEFAULT 'open',
- user_id INT REFERENCES users(id) ON DELETE SET NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- `;
-
- const createSolutionsTable = `
- CREATE TABLE IF NOT EXISTS solutions (
- id SERIAL PRIMARY KEY,
- issue_id INT REFERENCES issues(id) ON DELETE CASCADE,
- user_id INT REFERENCES users(id) ON DELETE SET NULL,
- description TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- `;
-
- try {
- await pool.query(createUsersTable);
- await pool.query(createIssuesTable);
- await pool.query(createSolutionsTable);
- } catch (error) {
- console.error("❌ Error creating tables:", error);
- }
-};
-
-createTables();
-
-export default pool;
\ No newline at end of file
diff --git a/backend/middlewares/protectRoutes.js b/backend/middlewares/protectRoutes.js
deleted file mode 100644
index 1a3c1ed..0000000
--- a/backend/middlewares/protectRoutes.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import jwt from "jsonwebtoken";
-import pool from "../database/db.js";
-
-const protectRoutes = async (req, res, next) => {
- try{
- const token = req.cookies.jwtCookie;
- if(!token){
- return res.status(401).json({error: "Unauthorized:- No token provided"});
- }
- const decoded = jwt.verify(token, process.env.JWT_SECRET);
- if(!decoded){
- return res.status(401).json({error: "Invalid Token"});
- }
- const userId = decoded.userID;
- const { rows } = await pool.query("SELECT * FROM users WHERE id=$1", [userId]);
- if(rows.length === 0) {
- return res.status(401).json({error: "User does not exist!"});
- }
- req.user = {
- id: rows[0].id,
- name: rows[0].name,
- admission_number: rows[0].admission_number,
- gender: rows[0].gender,
- profilePic: rows[0].profilePic,
- role: rows[0].role
- };
- next();
- } catch(error){
- console.log("Error in ProtectRoutes MiddleWare", error);
- return res.status(500).json({error: "Internal Server Error"});
- }
-}
-
-export default protectRoutes;
\ No newline at end of file
diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js
deleted file mode 100644
index a1ddba2..0000000
--- a/backend/routes/authRoutes.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import express from "express";
-import { register, login, logout } from "../controllers/authControllers.js";
-
-const router = express.Router();
-
-router.post("/register", register);
-router.post("/login", login);
-router.post("/logout", logout);
-
-export default router;
\ No newline at end of file
diff --git a/backend/routes/issuesRoutes.js b/backend/routes/issuesRoutes.js
deleted file mode 100644
index 80c1eb0..0000000
--- a/backend/routes/issuesRoutes.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import express from "express";
-import { deleteIssue, getAllIssues, getIssue, reportIssue, updateIssue } from "../controllers/issuesControllers.js";
-import protectRoutes from "../middlewares/protectRoutes.js";
-const router = express.Router();
-
-router.get('/', getAllIssues);
-router.post('/',protectRoutes, reportIssue);
-
-router.get('/:id', protectRoutes, getIssue);
-router.put('/:id', protectRoutes, updateIssue);
-router.delete('/:id', protectRoutes, deleteIssue);
-
-export default router;
\ No newline at end of file
diff --git a/backend/routes/solutionRoutes.js b/backend/routes/solutionRoutes.js
deleted file mode 100644
index de41b07..0000000
--- a/backend/routes/solutionRoutes.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import express from "express";
-import protectRoutes from "../middlewares/protectRoutes.js";
-import { createSolution, getSolution } from "../controllers/solutionControllers.js";
-const router = express.Router();
-
-router.get("/:issue_id", protectRoutes, getSolution);
-router.post("/:issue_id", protectRoutes, createSolution);
-
-export default router;
\ No newline at end of file
diff --git a/backend/server.js b/backend/server.js
deleted file mode 100644
index 4eb47a9..0000000
--- a/backend/server.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import express from "express";
-import dotenv from "dotenv";
-import cookieParser from "cookie-parser";
-import authRoutes from "./routes/authRoutes.js";
-import issuesRoutes from "./routes/issuesRoutes.js";
-import solutionRoutes from "./routes/solutionRoutes.js";
-
-dotenv.config();
-const app = express();
-const port = process.env.PORT;
-
-app.use(express.json());
-app.use(cookieParser());
-
-app.use("/api/auth", authRoutes);
-app.use("/api/issues", issuesRoutes);
-app.use("/api/solution", solutionRoutes);
-
-app.listen(port, ()=>{
- console.log(`Server running on port: ${port}`);
-});
diff --git a/backend/src/app.js b/backend/src/app.js
new file mode 100644
index 0000000..5ac5289
--- /dev/null
+++ b/backend/src/app.js
@@ -0,0 +1,31 @@
+import express from "express";
+import cookieParser from "cookie-parser";
+
+import authRoutes from "./modules/auth/auth.routes.js";
+import issuesRoutes from "./modules/issues/issues.routes.js";
+import solutionRoutes from "./modules/solutions/solutions.routes.js";
+
+import { errorHandler } from "./middlewares/errorHandler.js";
+import { notFound } from "./middlewares/notFound.js";
+import { requestLogger } from "./middlewares/requestLogger.js";
+
+export const createApp = () => {
+ const app = express();
+
+ app.use(express.json());
+ app.use(cookieParser());
+ app.use(requestLogger);
+
+ app.get("/api/health", (_req, res) => {
+ res.json({ status: "ok", uptime: process.uptime() });
+ });
+
+ app.use("/api/auth", authRoutes);
+ app.use("/api/issues", issuesRoutes);
+ app.use("/api/solution", solutionRoutes);
+
+ app.use(notFound);
+ app.use(errorHandler);
+
+ return app;
+};
diff --git a/backend/src/config/constants.js b/backend/src/config/constants.js
new file mode 100644
index 0000000..7624b4d
--- /dev/null
+++ b/backend/src/config/constants.js
@@ -0,0 +1,13 @@
+export const ROLES = Object.freeze({
+ USER: "user",
+ ADMIN: "admin",
+});
+
+export const ISSUE_STATUS = Object.freeze({
+ OPEN: "open",
+ IN_PROGRESS: "in-progress",
+ RESOLVED: "resolved",
+ CLOSED: "closed",
+});
+
+export const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
diff --git a/backend/src/config/env.js b/backend/src/config/env.js
new file mode 100644
index 0000000..e99ed31
--- /dev/null
+++ b/backend/src/config/env.js
@@ -0,0 +1,34 @@
+import dotenv from "dotenv";
+
+dotenv.config();
+
+const required = (name, fallback) => {
+ const value = process.env[name] ?? fallback;
+ if (value === undefined || value === null || value === "") {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+ return value;
+};
+
+const optional = (name, fallback) => process.env[name] ?? fallback;
+
+export const env = {
+ nodeEnv: optional("NODE_ENV", "development"),
+ port: Number(optional("PORT", "3000")),
+
+ jwt: {
+ secret: required("JWT_SECRET"),
+ expiresIn: optional("JWT_EXPIRES_IN", "30d"),
+ cookieName: optional("JWT_COOKIE_NAME", "jwtCookie"),
+ },
+
+ db: {
+ user: required("DB_USER"),
+ host: required("DB_HOST"),
+ database: required("DATABASE"),
+ password: required("DB_PASSWORD"),
+ port: Number(required("DB_PORT")),
+ },
+};
+
+export const isProd = env.nodeEnv === "production";
diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js
new file mode 100644
index 0000000..32c11ac
--- /dev/null
+++ b/backend/src/db/migrate.js
@@ -0,0 +1,38 @@
+import pool from "./pool.js";
+import { logger } from "../utils/logger.js";
+
+const STATEMENTS = [
+ `CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ admission_number VARCHAR(255) UNIQUE NOT NULL,
+ password VARCHAR(255) NOT NULL,
+ gender VARCHAR(50) NOT NULL,
+ profilePic VARCHAR(255) NOT NULL,
+ role VARCHAR(50) DEFAULT 'user',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`,
+ `CREATE TABLE IF NOT EXISTS issues (
+ id SERIAL PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ status VARCHAR(50) DEFAULT 'open',
+ user_id INT REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`,
+ `CREATE TABLE IF NOT EXISTS solutions (
+ id SERIAL PRIMARY KEY,
+ issue_id INT REFERENCES issues(id) ON DELETE CASCADE,
+ user_id INT REFERENCES users(id) ON DELETE SET NULL,
+ description TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );`,
+];
+
+export async function runMigrations() {
+ for (const sql of STATEMENTS) {
+ await pool.query(sql);
+ }
+ logger.info("database schema ensured");
+}
diff --git a/backend/src/db/pool.js b/backend/src/db/pool.js
new file mode 100644
index 0000000..68ac1e3
--- /dev/null
+++ b/backend/src/db/pool.js
@@ -0,0 +1,17 @@
+import pg from "pg";
+import { env } from "../config/env.js";
+
+const pool = new pg.Pool({
+ user: env.db.user,
+ host: env.db.host,
+ database: env.db.database,
+ password: env.db.password,
+ port: env.db.port,
+});
+
+pool.on("error", (err) => {
+ // eslint-disable-next-line no-console
+ console.error("[pg] unexpected pool error", err);
+});
+
+export default pool;
diff --git a/backend/src/middlewares/asyncHandler.js b/backend/src/middlewares/asyncHandler.js
new file mode 100644
index 0000000..ca25e85
--- /dev/null
+++ b/backend/src/middlewares/asyncHandler.js
@@ -0,0 +1,3 @@
+export const asyncHandler = (fn) => (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+};
diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js
new file mode 100644
index 0000000..a6d300f
--- /dev/null
+++ b/backend/src/middlewares/authenticate.js
@@ -0,0 +1,30 @@
+import { env } from "../config/env.js";
+import { verifyAuthToken } from "../utils/jwt.js";
+import { ApiError } from "../utils/ApiError.js";
+import { asyncHandler } from "./asyncHandler.js";
+import * as usersRepo from "../modules/auth/auth.repository.js";
+
+export const authenticate = asyncHandler(async (req, _res, next) => {
+ const token = req.cookies?.[env.jwt.cookieName];
+ if (!token) throw ApiError.unauthorized("No token provided");
+
+ let decoded;
+ try {
+ decoded = verifyAuthToken(token);
+ } catch {
+ throw ApiError.unauthorized("Invalid or expired token");
+ }
+
+ const user = await usersRepo.findById(decoded.userID);
+ if (!user) throw ApiError.unauthorized("User no longer exists");
+
+ req.user = {
+ id: user.id,
+ name: user.name,
+ admission_number: user.admission_number,
+ gender: user.gender,
+ profilePic: user.profilepic,
+ role: user.role,
+ };
+ next();
+});
diff --git a/backend/src/middlewares/authorize.js b/backend/src/middlewares/authorize.js
new file mode 100644
index 0000000..434ec80
--- /dev/null
+++ b/backend/src/middlewares/authorize.js
@@ -0,0 +1,9 @@
+import { ApiError } from "../utils/ApiError.js";
+
+export const authorize = (...allowedRoles) => (req, _res, next) => {
+ if (!req.user) return next(ApiError.unauthorized());
+ if (!allowedRoles.includes(req.user.role)) {
+ return next(ApiError.forbidden("Access denied"));
+ }
+ return next();
+};
diff --git a/backend/src/middlewares/errorHandler.js b/backend/src/middlewares/errorHandler.js
new file mode 100644
index 0000000..2bb56b9
--- /dev/null
+++ b/backend/src/middlewares/errorHandler.js
@@ -0,0 +1,22 @@
+import { ApiError } from "../utils/ApiError.js";
+import { logger } from "../utils/logger.js";
+import { isProd } from "../config/env.js";
+
+// eslint-disable-next-line no-unused-vars
+export const errorHandler = (err, req, res, _next) => {
+ const isApi = err instanceof ApiError;
+ const statusCode = isApi ? err.statusCode : 500;
+
+ if (!isApi || statusCode >= 500) {
+ logger.error(`${req.method} ${req.originalUrl}`, err);
+ }
+
+ const payload = {
+ error: isApi ? err.message : "Internal server error",
+ };
+ if (isApi && err.code) payload.code = err.code;
+ if (isApi && err.details) payload.details = err.details;
+ if (!isProd && !isApi) payload.stack = err.stack;
+
+ res.status(statusCode).json(payload);
+};
diff --git a/backend/src/middlewares/notFound.js b/backend/src/middlewares/notFound.js
new file mode 100644
index 0000000..33af17a
--- /dev/null
+++ b/backend/src/middlewares/notFound.js
@@ -0,0 +1,5 @@
+import { ApiError } from "../utils/ApiError.js";
+
+export const notFound = (req, _res, next) => {
+ next(ApiError.notFound(`Route not found: ${req.method} ${req.originalUrl}`));
+};
diff --git a/backend/src/middlewares/requestLogger.js b/backend/src/middlewares/requestLogger.js
new file mode 100644
index 0000000..d9bb555
--- /dev/null
+++ b/backend/src/middlewares/requestLogger.js
@@ -0,0 +1,10 @@
+import { logger } from "../utils/logger.js";
+
+export const requestLogger = (req, res, next) => {
+ const start = Date.now();
+ res.on("finish", () => {
+ const ms = Date.now() - start;
+ logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
+ });
+ next();
+};
diff --git a/backend/src/modules/auth/auth.controller.js b/backend/src/modules/auth/auth.controller.js
new file mode 100644
index 0000000..f056b47
--- /dev/null
+++ b/backend/src/modules/auth/auth.controller.js
@@ -0,0 +1,24 @@
+import { asyncHandler } from "../../middlewares/asyncHandler.js";
+import { clearAuthCookie, setAuthCookie, signAuthToken } from "../../utils/jwt.js";
+import * as service from "./auth.service.js";
+
+export const register = asyncHandler(async (req, res) => {
+ const user = await service.register(req.body);
+ setAuthCookie(res, signAuthToken(user.id));
+ res.status(201).json(user);
+});
+
+export const login = asyncHandler(async (req, res) => {
+ const user = await service.login(req.body);
+ setAuthCookie(res, signAuthToken(user.id));
+ res.status(200).json(user);
+});
+
+export const logout = asyncHandler(async (_req, res) => {
+ clearAuthCookie(res);
+ res.status(200).json({ message: "Logged out successfully" });
+});
+
+export const me = asyncHandler(async (req, res) => {
+ res.status(200).json(req.user);
+});
diff --git a/backend/src/modules/auth/auth.repository.js b/backend/src/modules/auth/auth.repository.js
new file mode 100644
index 0000000..f73fe25
--- /dev/null
+++ b/backend/src/modules/auth/auth.repository.js
@@ -0,0 +1,30 @@
+import pool from "../../db/pool.js";
+
+export const findByAdmissionNumber = async (admissionNumber) => {
+ const { rows } = await pool.query(
+ "SELECT * FROM users WHERE admission_number = $1",
+ [admissionNumber],
+ );
+ return rows[0] ?? null;
+};
+
+export const findById = async (id) => {
+ const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [id]);
+ return rows[0] ?? null;
+};
+
+export const createUser = async ({
+ name,
+ admissionNumber,
+ hashedPassword,
+ gender,
+ profilePic,
+}) => {
+ const { rows } = await pool.query(
+ `INSERT INTO users (name, admission_number, password, gender, profilePic, role)
+ VALUES ($1, $2, $3, $4, $5, DEFAULT)
+ RETURNING *`,
+ [name, admissionNumber, hashedPassword, gender, profilePic],
+ );
+ return rows[0];
+};
diff --git a/backend/src/modules/auth/auth.routes.js b/backend/src/modules/auth/auth.routes.js
new file mode 100644
index 0000000..c6168a2
--- /dev/null
+++ b/backend/src/modules/auth/auth.routes.js
@@ -0,0 +1,12 @@
+import { Router } from "express";
+import { authenticate } from "../../middlewares/authenticate.js";
+import * as controller from "./auth.controller.js";
+
+const router = Router();
+
+router.post("/register", controller.register);
+router.post("/login", controller.login);
+router.post("/logout", controller.logout);
+router.get("/me", authenticate, controller.me);
+
+export default router;
diff --git a/backend/src/modules/auth/auth.service.js b/backend/src/modules/auth/auth.service.js
new file mode 100644
index 0000000..c74f046
--- /dev/null
+++ b/backend/src/modules/auth/auth.service.js
@@ -0,0 +1,62 @@
+import { ApiError } from "../../utils/ApiError.js";
+import { comparePassword, hashPassword } from "../../utils/password.js";
+import * as repo from "./auth.repository.js";
+
+const buildProfilePic = (gender) =>
+ gender === "male"
+ ? "https://avatar.iran.liara.run/public/boy"
+ : "https://avatar.iran.liara.run/public/girl";
+
+const toPublicUser = (user) => ({
+ id: user.id,
+ name: user.name,
+ admission_number: user.admission_number,
+ gender: user.gender,
+ profilePic: user.profilepic,
+ role: user.role,
+});
+
+export const register = async ({
+ name,
+ admission_number,
+ password,
+ confirmPassword,
+ gender,
+}) => {
+ if (!name || !admission_number || !password || !gender) {
+ throw ApiError.badRequest("Missing required fields");
+ }
+ if (password !== confirmPassword) {
+ throw ApiError.badRequest("Passwords do not match");
+ }
+ if (!["male", "female"].includes(gender)) {
+ throw ApiError.badRequest("Gender must be 'male' or 'female'");
+ }
+
+ const existing = await repo.findByAdmissionNumber(admission_number);
+ if (existing) throw ApiError.conflict("User already exists");
+
+ const hashed = await hashPassword(password);
+ const user = await repo.createUser({
+ name,
+ admissionNumber: admission_number,
+ hashedPassword: hashed,
+ gender,
+ profilePic: buildProfilePic(gender),
+ });
+
+ return toPublicUser(user);
+};
+
+export const login = async ({ admission_number, password }) => {
+ if (!admission_number || !password) {
+ throw ApiError.badRequest("Admission number and password are required");
+ }
+
+ const user = await repo.findByAdmissionNumber(admission_number);
+ if (!user || !(await comparePassword(password, user.password))) {
+ throw ApiError.badRequest("Invalid admission number or password");
+ }
+
+ return toPublicUser(user);
+};
diff --git a/backend/src/modules/issues/issues.controller.js b/backend/src/modules/issues/issues.controller.js
new file mode 100644
index 0000000..ea34334
--- /dev/null
+++ b/backend/src/modules/issues/issues.controller.js
@@ -0,0 +1,38 @@
+import { asyncHandler } from "../../middlewares/asyncHandler.js";
+import * as service from "./issues.service.js";
+
+export const list = asyncHandler(async (_req, res) => {
+ const issues = await service.listIssues();
+ res.status(200).json(issues);
+});
+
+export const get = asyncHandler(async (req, res) => {
+ const issue = await service.getIssue(req.params.id);
+ res.status(200).json(issue);
+});
+
+export const create = asyncHandler(async (req, res) => {
+ const { title, description } = req.body;
+ const issue = await service.reportIssue({
+ title,
+ description,
+ userId: req.user.id,
+ });
+ res.status(201).json(issue);
+});
+
+export const update = asyncHandler(async (req, res) => {
+ const { title, description } = req.body;
+ const issue = await service.updateIssue({
+ id: req.params.id,
+ title,
+ description,
+ userId: req.user.id,
+ });
+ res.status(200).json(issue);
+});
+
+export const remove = asyncHandler(async (req, res) => {
+ await service.deleteIssue({ id: req.params.id, userId: req.user.id });
+ res.status(200).json({ message: "Issue deleted successfully" });
+});
diff --git a/backend/src/modules/issues/issues.repository.js b/backend/src/modules/issues/issues.repository.js
new file mode 100644
index 0000000..af9a0c8
--- /dev/null
+++ b/backend/src/modules/issues/issues.repository.js
@@ -0,0 +1,50 @@
+import pool from "../../db/pool.js";
+
+const LIST_QUERY = `
+ SELECT issues.*, users.name AS created_by
+ FROM issues
+ LEFT JOIN users ON issues.user_id = users.id
+ ORDER BY issues.created_at DESC
+`;
+
+const DETAIL_QUERY = `
+ SELECT issues.*, users.name AS created_by
+ FROM issues
+ LEFT JOIN users ON issues.user_id = users.id
+ WHERE issues.id = $1
+`;
+
+export const listAll = async () => {
+ const { rows } = await pool.query(LIST_QUERY);
+ return rows;
+};
+
+export const findById = async (id) => {
+ const { rows } = await pool.query(DETAIL_QUERY, [id]);
+ return rows[0] ?? null;
+};
+
+export const create = async ({ title, description, userId }) => {
+ const { rows } = await pool.query(
+ `INSERT INTO issues (title, description, user_id)
+ VALUES ($1, $2, $3)
+ RETURNING *`,
+ [title, description, userId],
+ );
+ return rows[0];
+};
+
+export const update = async ({ id, title, description }) => {
+ const { rows } = await pool.query(
+ `UPDATE issues
+ SET title = $1, description = $2, updated_at = NOW()
+ WHERE id = $3
+ RETURNING *`,
+ [title, description, id],
+ );
+ return rows[0];
+};
+
+export const remove = async (id) => {
+ await pool.query("DELETE FROM issues WHERE id = $1", [id]);
+};
diff --git a/backend/src/modules/issues/issues.routes.js b/backend/src/modules/issues/issues.routes.js
new file mode 100644
index 0000000..e8970f1
--- /dev/null
+++ b/backend/src/modules/issues/issues.routes.js
@@ -0,0 +1,14 @@
+import { Router } from "express";
+import { authenticate } from "../../middlewares/authenticate.js";
+import * as controller from "./issues.controller.js";
+
+const router = Router();
+
+router.get("/", controller.list);
+router.post("/", authenticate, controller.create);
+
+router.get("/:id", authenticate, controller.get);
+router.put("/:id", authenticate, controller.update);
+router.delete("/:id", authenticate, controller.remove);
+
+export default router;
diff --git a/backend/src/modules/issues/issues.service.js b/backend/src/modules/issues/issues.service.js
new file mode 100644
index 0000000..f043150
--- /dev/null
+++ b/backend/src/modules/issues/issues.service.js
@@ -0,0 +1,39 @@
+import { ApiError } from "../../utils/ApiError.js";
+import * as repo from "./issues.repository.js";
+
+export const listIssues = () => repo.listAll();
+
+export const getIssue = async (id) => {
+ const issue = await repo.findById(id);
+ if (!issue) throw ApiError.notFound("Issue not found");
+ return issue;
+};
+
+export const reportIssue = async ({ title, description, userId }) => {
+ if (!title?.trim()) throw ApiError.badRequest("Title is required");
+ if (!description?.trim()) throw ApiError.badRequest("Description is required");
+ return repo.create({ title: title.trim(), description: description.trim(), userId });
+};
+
+const assertOwnership = (issue, userId) => {
+ if (issue.user_id !== userId) {
+ throw ApiError.forbidden("You are not the author of this issue");
+ }
+};
+
+export const updateIssue = async ({ id, title, description, userId }) => {
+ const existing = await repo.findById(id);
+ if (!existing) throw ApiError.notFound("Issue not found");
+ assertOwnership(existing, userId);
+
+ const nextTitle = title?.trim() || existing.title;
+ const nextDescription = description?.trim() ?? existing.description;
+ return repo.update({ id, title: nextTitle, description: nextDescription });
+};
+
+export const deleteIssue = async ({ id, userId }) => {
+ const existing = await repo.findById(id);
+ if (!existing) throw ApiError.notFound("Issue not found");
+ assertOwnership(existing, userId);
+ await repo.remove(id);
+};
diff --git a/backend/src/modules/solutions/solutions.controller.js b/backend/src/modules/solutions/solutions.controller.js
new file mode 100644
index 0000000..515efc6
--- /dev/null
+++ b/backend/src/modules/solutions/solutions.controller.js
@@ -0,0 +1,16 @@
+import { asyncHandler } from "../../middlewares/asyncHandler.js";
+import * as service from "./solutions.service.js";
+
+export const get = asyncHandler(async (req, res) => {
+ const solution = await service.getSolution(req.params.issue_id);
+ res.status(200).json(solution);
+});
+
+export const create = asyncHandler(async (req, res) => {
+ const solution = await service.createSolution({
+ issueId: req.params.issue_id,
+ userId: req.user.id,
+ description: req.body.description,
+ });
+ res.status(201).json(solution);
+});
diff --git a/backend/src/modules/solutions/solutions.repository.js b/backend/src/modules/solutions/solutions.repository.js
new file mode 100644
index 0000000..070f378
--- /dev/null
+++ b/backend/src/modules/solutions/solutions.repository.js
@@ -0,0 +1,19 @@
+import pool from "../../db/pool.js";
+
+export const findByIssueId = async (issueId) => {
+ const { rows } = await pool.query(
+ "SELECT * FROM solutions WHERE issue_id = $1 ORDER BY created_at DESC LIMIT 1",
+ [issueId],
+ );
+ return rows[0] ?? null;
+};
+
+export const create = async ({ issueId, userId, description }) => {
+ const { rows } = await pool.query(
+ `INSERT INTO solutions (issue_id, user_id, description)
+ VALUES ($1, $2, $3)
+ RETURNING *`,
+ [issueId, userId, description],
+ );
+ return rows[0];
+};
diff --git a/backend/src/modules/solutions/solutions.routes.js b/backend/src/modules/solutions/solutions.routes.js
new file mode 100644
index 0000000..786a2f6
--- /dev/null
+++ b/backend/src/modules/solutions/solutions.routes.js
@@ -0,0 +1,12 @@
+import { Router } from "express";
+import { authenticate } from "../../middlewares/authenticate.js";
+import { authorize } from "../../middlewares/authorize.js";
+import { ROLES } from "../../config/constants.js";
+import * as controller from "./solutions.controller.js";
+
+const router = Router();
+
+router.get("/:issue_id", authenticate, controller.get);
+router.post("/:issue_id", authenticate, authorize(ROLES.ADMIN), controller.create);
+
+export default router;
diff --git a/backend/src/modules/solutions/solutions.service.js b/backend/src/modules/solutions/solutions.service.js
new file mode 100644
index 0000000..fdc0032
--- /dev/null
+++ b/backend/src/modules/solutions/solutions.service.js
@@ -0,0 +1,15 @@
+import { ApiError } from "../../utils/ApiError.js";
+import * as repo from "./solutions.repository.js";
+
+export const getSolution = async (issueId) => {
+ const solution = await repo.findByIssueId(issueId);
+ if (!solution) return { description: null, created_at: null };
+ return solution;
+};
+
+export const createSolution = async ({ issueId, userId, description }) => {
+ if (!description?.trim()) {
+ throw ApiError.badRequest("Solution description is required");
+ }
+ return repo.create({ issueId, userId, description: description.trim() });
+};
diff --git a/backend/src/server.js b/backend/src/server.js
new file mode 100644
index 0000000..0779bc0
--- /dev/null
+++ b/backend/src/server.js
@@ -0,0 +1,35 @@
+import { createApp } from "./app.js";
+import { env } from "./config/env.js";
+import { runMigrations } from "./db/migrate.js";
+import { logger } from "./utils/logger.js";
+
+const start = async () => {
+ try {
+ await runMigrations();
+ } catch (err) {
+ logger.error("migration failed", err);
+ process.exit(1);
+ }
+
+ const app = createApp();
+ const server = app.listen(env.port, () => {
+ logger.info(`server listening on port ${env.port} (${env.nodeEnv})`);
+ });
+
+ const shutdown = (signal) => {
+ logger.info(`received ${signal}, shutting down`);
+ server.close(() => process.exit(0));
+ };
+
+ process.on("SIGINT", () => shutdown("SIGINT"));
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
+ process.on("unhandledRejection", (reason) => {
+ logger.error("unhandledRejection", reason);
+ });
+ process.on("uncaughtException", (err) => {
+ logger.error("uncaughtException", err);
+ process.exit(1);
+ });
+};
+
+start();
diff --git a/backend/src/utils/ApiError.js b/backend/src/utils/ApiError.js
new file mode 100644
index 0000000..6fc9e77
--- /dev/null
+++ b/backend/src/utils/ApiError.js
@@ -0,0 +1,35 @@
+export class ApiError extends Error {
+ constructor(statusCode, message, { code, details } = {}) {
+ super(message);
+ this.name = "ApiError";
+ this.statusCode = statusCode;
+ this.code = code;
+ this.details = details;
+ this.isOperational = true;
+ Error.captureStackTrace?.(this, this.constructor);
+ }
+
+ static badRequest(message, details) {
+ return new ApiError(400, message, { code: "BAD_REQUEST", details });
+ }
+
+ static unauthorized(message = "Unauthorized") {
+ return new ApiError(401, message, { code: "UNAUTHORIZED" });
+ }
+
+ static forbidden(message = "Forbidden") {
+ return new ApiError(403, message, { code: "FORBIDDEN" });
+ }
+
+ static notFound(message = "Not found") {
+ return new ApiError(404, message, { code: "NOT_FOUND" });
+ }
+
+ static conflict(message) {
+ return new ApiError(409, message, { code: "CONFLICT" });
+ }
+
+ static internal(message = "Internal server error") {
+ return new ApiError(500, message, { code: "INTERNAL" });
+ }
+}
diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js
new file mode 100644
index 0000000..493c91e
--- /dev/null
+++ b/backend/src/utils/jwt.js
@@ -0,0 +1,21 @@
+import jwt from "jsonwebtoken";
+import { env, isProd } from "../config/env.js";
+import { COOKIE_MAX_AGE_MS } from "../config/constants.js";
+
+export const signAuthToken = (userId) =>
+ jwt.sign({ userID: userId }, env.jwt.secret, { expiresIn: env.jwt.expiresIn });
+
+export const verifyAuthToken = (token) => jwt.verify(token, env.jwt.secret);
+
+export const setAuthCookie = (res, token) => {
+ res.cookie(env.jwt.cookieName, token, {
+ maxAge: COOKIE_MAX_AGE_MS,
+ httpOnly: true,
+ sameSite: "strict",
+ secure: isProd,
+ });
+};
+
+export const clearAuthCookie = (res) => {
+ res.clearCookie(env.jwt.cookieName);
+};
diff --git a/backend/src/utils/logger.js b/backend/src/utils/logger.js
new file mode 100644
index 0000000..951fea3
--- /dev/null
+++ b/backend/src/utils/logger.js
@@ -0,0 +1,26 @@
+import { isProd } from "../config/env.js";
+
+const stamp = () => new Date().toISOString();
+
+const write = (level, args) => {
+ const prefix = `[${stamp()}] [${level}]`;
+ if (level === "error") {
+ // eslint-disable-next-line no-console
+ console.error(prefix, ...args);
+ } else if (level === "warn") {
+ // eslint-disable-next-line no-console
+ console.warn(prefix, ...args);
+ } else {
+ // eslint-disable-next-line no-console
+ console.log(prefix, ...args);
+ }
+};
+
+export const logger = {
+ info: (...args) => write("info", args),
+ warn: (...args) => write("warn", args),
+ error: (...args) => write("error", args),
+ debug: (...args) => {
+ if (!isProd) write("debug", args);
+ },
+};
diff --git a/backend/src/utils/password.js b/backend/src/utils/password.js
new file mode 100644
index 0000000..979b8cd
--- /dev/null
+++ b/backend/src/utils/password.js
@@ -0,0 +1,7 @@
+import bcrypt from "bcrypt";
+
+const SALT_ROUNDS = 10;
+
+export const hashPassword = (plain) => bcrypt.hash(plain, SALT_ROUNDS);
+
+export const comparePassword = (plain, hash) => bcrypt.compare(plain, hash);
diff --git a/backend/utils/jwt.js b/backend/utils/jwt.js
deleted file mode 100644
index 8734897..0000000
--- a/backend/utils/jwt.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import jwt from "jsonwebtoken";
-import env from "dotenv";
-env.config();
-
-const generateJWTandSetCookie = (userID, res) => {
- try{
- const token = jwt.sign({userID}, process.env.JWT_SECRET, {
- expiresIn: "30d",
- });
- res.cookie("jwtCookie", token, {
- maxAge: 30*24*60*60*1000,
- httpOnly: true,
- sameSite: "strict",
- });
- } catch(error){
- console.log("JWT generation failed:", error);
- throw new Error("Failed to generate authentication token");
- }
-}
-
-export default generateJWTandSetCookie;
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index 5cbec54..05e3b1d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,17 +1,20 @@
-
-
-
-
-
-
-
-
- Vite + React
-
-
-
-
-
+
+
+
+
+
+
+
+
+ IssueTrack — Institutional Issue Tracker
+
+
+
+
+
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c3725e7..724ef9a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,30 +1,27 @@
-import { Navigate, Route, Routes } from "react-router-dom"
-import { Toaster } from "react-hot-toast"
-import Register from "./pages/auth/register"
-import Home from "./pages/home/Home"
-import Login from "./pages/auth/Login"
-import Issues from "./pages/issues/Issues"
-import { useContext, useEffect } from "react"
-import { authContext } from "./context/authContext"
-import Issue from "./pages/issue/Issue"
+import { Toaster } from "react-hot-toast";
+import AppRoutes from "./routes/AppRoutes.jsx";
function App() {
- const { authUser } = useContext(authContext);
- useEffect(()=>{
-
- }, [authUser]);
- return(
- <>
-
-
- } />
- : }/>
- : }/>
- : }/>
- : }/>
-
- >
- )
+ return (
+ <>
+
+
+ >
+ );
}
-export default App
+export default App;
diff --git a/frontend/src/api/auth.api.js b/frontend/src/api/auth.api.js
new file mode 100644
index 0000000..e2526cc
--- /dev/null
+++ b/frontend/src/api/auth.api.js
@@ -0,0 +1,6 @@
+import { api } from "./client.js";
+
+export const loginRequest = (payload) => api.post("/api/auth/login", payload);
+export const registerRequest = (payload) => api.post("/api/auth/register", payload);
+export const logoutRequest = () => api.post("/api/auth/logout");
+export const getCurrentUser = () => api.get("/api/auth/me");
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..68f6054
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -0,0 +1,55 @@
+export class ApiClientError extends Error {
+ constructor(message, { status, code, details } = {}) {
+ super(message);
+ this.name = "ApiClientError";
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
+const parseBody = async (res) => {
+ const text = await res.text();
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+};
+
+export const request = async (path, { method = "GET", body, headers } = {}) => {
+ const res = await fetch(path, {
+ method,
+ credentials: "include",
+ headers: {
+ Accept: "application/json",
+ ...(body ? { "Content-Type": "application/json" } : {}),
+ ...headers,
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ const data = await parseBody(res);
+
+ if (!res.ok) {
+ const message =
+ (data && typeof data === "object" && data.error) ||
+ (typeof data === "string" && data) ||
+ `Request failed with status ${res.status}`;
+ throw new ApiClientError(message, {
+ status: res.status,
+ code: data?.code,
+ details: data?.details,
+ });
+ }
+
+ return data;
+};
+
+export const api = {
+ get: (path, options) => request(path, { ...options, method: "GET" }),
+ post: (path, body, options) => request(path, { ...options, method: "POST", body }),
+ put: (path, body, options) => request(path, { ...options, method: "PUT", body }),
+ delete: (path, options) => request(path, { ...options, method: "DELETE" }),
+};
diff --git a/frontend/src/api/issues.api.js b/frontend/src/api/issues.api.js
new file mode 100644
index 0000000..36687c4
--- /dev/null
+++ b/frontend/src/api/issues.api.js
@@ -0,0 +1,7 @@
+import { api } from "./client.js";
+
+export const listIssues = () => api.get("/api/issues");
+export const getIssue = (id) => api.get(`/api/issues/${id}`);
+export const createIssue = (payload) => api.post("/api/issues", payload);
+export const updateIssue = (id, payload) => api.put(`/api/issues/${id}`, payload);
+export const deleteIssue = (id) => api.delete(`/api/issues/${id}`);
diff --git a/frontend/src/api/solutions.api.js b/frontend/src/api/solutions.api.js
new file mode 100644
index 0000000..0e69a6c
--- /dev/null
+++ b/frontend/src/api/solutions.api.js
@@ -0,0 +1,5 @@
+import { api } from "./client.js";
+
+export const getSolution = (issueId) => api.get(`/api/solution/${issueId}`);
+export const createSolution = (issueId, payload) =>
+ api.post(`/api/solution/${issueId}`, payload);
diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx
deleted file mode 100644
index 86eaec3..0000000
--- a/frontend/src/components/IssueCard.jsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import toast from 'react-hot-toast';
-import { MdDelete } from "react-icons/md";
-import { authContext } from '../context/authContext';
-
-const IssueCard = ({ issue }) => {
- const { authUser } = useContext(authContext);
- useEffect(()=>{
-
- }, [authUser]);
- const navigate = useNavigate();
- const handleClick = ()=>{
- navigate(`/issues/${issue.id}`);
- }
- const [deleting, setDeleting] = useState(false);
-
- const handleDelete = async () => {
- // 1. Ask the user
- // const confirmed = window.confirm(
- // `Are you sure you want to delete the issue “${issue.title}”?`
- // );
- // if (!confirmed) return;
- setDeleting(true);
- try {
- const res = await fetch(`/api/issues/${issue.id}`, {
- method: 'DELETE',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
- const data = await res.json();
- if (data.error) {
- toast.error('Delete failed ');
- throw(data.error);
- }
- toast.success(`✅ Deleted`);
- } catch (err) {
- console.error('❌ Delete failed:', err);
- } finally {
- setDeleting(false);
- }
- };
- return (
-
-
{issue.title}
-
{issue.description}
-
-
-
- Status:
-
- {issue.status}
-
-
-
- Created by:
- {issue.created_by}
-
-
-
-
{deleting ? 'Deleting…' : 'Delete'}
-
-
-
);
-};
-
-export default IssueCard;
diff --git a/frontend/src/components/IssueDeatails.jsx b/frontend/src/components/IssueDeatails.jsx
deleted file mode 100644
index 3384f91..0000000
--- a/frontend/src/components/IssueDeatails.jsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { useEffect, useState } from "react";
-
-const IssueDetails = ({id}) => {
- const [issue, setIssue] = useState({title: " ", description: " ", status: "", created_by: "", updated_at : "1/1/25", created_at : "1/1/23"});
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const fetchIssue = async () => {
- try {
- const res = await fetch(`/api/issues/${id}`);
- const data = await res.json();
- if(data.error){
- throw new Error(data.error);
- }
- setIssue(data);
- console.log(data);
- } catch (err) {
- console.error(err);
- setError('Failed to load issue.');
- } finally {
- setLoading(false);
- }
- };
-
- fetchIssue();
- }, []);
-
- if (loading) return Loading issue details...
;
- if (error) return {error}
;
- if (!issue) return null;
-
- return (
-
-
{issue.title}
-
{issue.description}
-
-
-
- Status:{' '}
-
- {issue.status}
-
-
-
- Created By: {issue.created_by}
-
-
- Created At:{' '}
- {new Date(issue.created_at).toLocaleString()}
-
- {issue.updated_at && (
-
- Last Updated:{' '}
- {new Date(issue.updated_at).toLocaleString()}
-
- )}
-
-
- )
-}
-
-export default IssueDetails;
\ No newline at end of file
diff --git a/frontend/src/components/LogoutButton.jsx b/frontend/src/components/LogoutButton.jsx
deleted file mode 100644
index 5baaf90..0000000
--- a/frontend/src/components/LogoutButton.jsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { useContext } from "react";
-import toast from "react-hot-toast";
-import { BiLogOut } from "react-icons/bi";
-import { authContext } from "../context/authContext";
-
-const LogoutButton = () => {
- const { setAuthUser } = useContext(authContext);
- const logout = async ()=>{
- try{
- const res = await fetch("/api/auth/logout", {
- method: "POST",
- headers: {"Content-Type" : "application/json"},
- });
- const data = await res.json();
- if(data.error){
- throw new Error(data.error);
- }
- localStorage.removeItem("user");
- setAuthUser(null);
- } catch(error){
- toast.error(error.message);
- }
- }
- return (
-
-
-
-
- );
-}
-
-export default LogoutButton;
diff --git a/frontend/src/components/Solution.jsx b/frontend/src/components/Solution.jsx
deleted file mode 100644
index 344d530..0000000
--- a/frontend/src/components/Solution.jsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React, { useEffect, useState, useContext } from 'react';
-import {toast} from "react-hot-toast";
-import SolutionForm from './SolutionForm';
-import { authContext } from '../context/authContext';
-
-
-const Solution = ({issueId}) => {
- const { authUser } = useContext(authContext);
- useEffect(()=>{
-
- }, [authUser]);
- console.log(authUser)
- const [solution, setSolution] = useState({
- description: '',
- created_at: ''
- });
- useEffect(()=>{
- const fetchSolution = async () => {
- try{
- const res = await fetch(`/api/solution/${issueId}`);
- const data = await res.json();
- if(data.error){
- throw new Error(data.error);
- }
- if(!data) setSolution(data)
- setSolution(data);
- } catch(error){
- toast.error(error.message);
- }
- }
- fetchSolution();
- }, [])
-return (
- solution.description ? (
-
-
Solution
-
- {solution.description}
-
-
-
- {solution.created_at ? new Date(solution.created_at).toLocaleString() : ''}
-
-
-
- ) : (
-
-
- No solution has been provided for this issue yet.
-
-
-
-
- )
- );
-
-};
-
-export default Solution;
diff --git a/frontend/src/components/SolutionForm.jsx b/frontend/src/components/SolutionForm.jsx
deleted file mode 100644
index 5d6c935..0000000
--- a/frontend/src/components/SolutionForm.jsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useState } from "react";
-
-const SolutionForm = ({issueId}) => {
-
- const [solution, setSolution] = useState('');
- const [submitting, setSubmitting] = useState(false);
- const [submitMessage, setSubmitMessage] = useState('');
-
- const handleSolutionSubmit = async (e) => {
- // e.preventDefault();
- // if (!solution.trim()) return;
- try {
- setSubmitting(true);
- const description = solution;
- const res = await fetch(`/api/solution/${issueId}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({description})
- });
- const data = await res.json();
- if(data.error){
- throw new Error(data.error);
- }
- setSubmitMessage('✅ Solution submitted successfully.');
- setSolution('');
- } catch (err) {
- console.error(err);
- setSubmitMessage('❌ Failed to submit solution.');
- } finally {
- setSubmitting(false);
- }
- };
-
- return(
-
-
Provide a Solution
-
-
-
- )
-}
-
-export default SolutionForm;
\ No newline at end of file
diff --git a/frontend/src/components/issues/IssueCard.jsx b/frontend/src/components/issues/IssueCard.jsx
new file mode 100644
index 0000000..b2651c1
--- /dev/null
+++ b/frontend/src/components/issues/IssueCard.jsx
@@ -0,0 +1,88 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import toast from "react-hot-toast";
+import { MdDelete } from "react-icons/md";
+import { HiArrowUpRight } from "react-icons/hi2";
+
+import { useAuth } from "../../hooks/useAuth.js";
+import { deleteIssue } from "../../api/issues.api.js";
+import { formatDate } from "../../lib/format.js";
+import StatusTag from "../ui/StatusTag.jsx";
+
+const IssueCard = ({ issue, onDeleted }) => {
+ const { authUser } = useAuth();
+ const [deleting, setDeleting] = useState(false);
+ const isOwner = authUser?.name === issue.created_by;
+
+ const handleDelete = async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (deleting) return;
+ setDeleting(true);
+ try {
+ await deleteIssue(issue.id);
+ toast.success("Issue deleted");
+ onDeleted?.(issue.id);
+ } catch (error) {
+ toast.error(error.message || "Delete failed");
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ #{String(issue.id).padStart(4, "0")}
+
+
+
+
+
+ {issue.title}
+
+
+ {issue.description}
+
+
+
+
+
+
+ Reporter
+
+
+ {issue.created_by || "—"}
+
+
+ {formatDate(issue.created_at)}
+
+
+
+
+ {isOwner && (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default IssueCard;
diff --git a/frontend/src/components/issues/IssueDetails.jsx b/frontend/src/components/issues/IssueDetails.jsx
new file mode 100644
index 0000000..f94332d
--- /dev/null
+++ b/frontend/src/components/issues/IssueDetails.jsx
@@ -0,0 +1,89 @@
+import { useEffect, useState } from "react";
+import toast from "react-hot-toast";
+
+import { getIssue } from "../../api/issues.api.js";
+import { formatDate } from "../../lib/format.js";
+import Panel, { PanelBody, PanelHeader, PanelTitle } from "../ui/Panel.jsx";
+import StatusTag from "../ui/StatusTag.jsx";
+import Spinner from "../ui/Spinner.jsx";
+
+const MetaRow = ({ label, value }) => (
+
+
+ {label}
+
+ {value}
+
+);
+
+const IssueDetails = ({ id }) => {
+ const [issue, setIssue] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let active = true;
+ setLoading(true);
+ getIssue(id)
+ .then((data) => {
+ if (active) setIssue(data);
+ })
+ .catch((err) => {
+ if (active) setError(err.message || "Failed to load issue");
+ toast.error(err.message || "Failed to load issue");
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [id]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error || !issue) {
+ return (
+
+
+
+ {error || "Issue not available"}
+
+
+
+ );
+ }
+
+ return (
+
+
+ Issue · #{String(issue.id).padStart(4, "0")}
+
+
+
+
+ {issue.title}
+
+
+ {issue.description}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default IssueDetails;
diff --git a/frontend/src/components/issues/IssueForm.jsx b/frontend/src/components/issues/IssueForm.jsx
new file mode 100644
index 0000000..ed0a119
--- /dev/null
+++ b/frontend/src/components/issues/IssueForm.jsx
@@ -0,0 +1,90 @@
+import { useState } from "react";
+import toast from "react-hot-toast";
+
+import Panel, { PanelBody, PanelHeader, PanelTitle } from "../ui/Panel.jsx";
+import Input from "../ui/Input.jsx";
+import Textarea from "../ui/Textarea.jsx";
+import Button from "../ui/Button.jsx";
+import { createIssue } from "../../api/issues.api.js";
+
+const initialState = { title: "", description: "" };
+
+const IssueForm = ({ onCreated }) => {
+ const [values, setValues] = useState(initialState);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleChange = (event) => {
+ const { name, value } = event.target;
+ setValues((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ if (!values.title.trim() || !values.description.trim()) {
+ setError("Title and description are required");
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ const created = await createIssue(values);
+ toast.success("Issue reported");
+ setValues(initialState);
+ onCreated?.(created);
+ } catch (err) {
+ setError(err.message || "Failed to raise issue");
+ toast.error(err.message || "Failed to raise issue");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ Report · new issue
+
+ because your voice matters
+
+
+
+
+
+
+ );
+};
+
+export default IssueForm;
diff --git a/frontend/src/components/layout/AppShell.jsx b/frontend/src/components/layout/AppShell.jsx
new file mode 100644
index 0000000..bcf1155
--- /dev/null
+++ b/frontend/src/components/layout/AppShell.jsx
@@ -0,0 +1,12 @@
+import TopBar from "./TopBar.jsx";
+import Footer from "./Footer.jsx";
+
+const AppShell = ({ children }) => (
+
+
+ {children}
+
+
+);
+
+export default AppShell;
diff --git a/frontend/src/components/layout/Footer.jsx b/frontend/src/components/layout/Footer.jsx
new file mode 100644
index 0000000..78136b1
--- /dev/null
+++ b/frontend/src/components/layout/Footer.jsx
@@ -0,0 +1,14 @@
+const Footer = () => (
+
+);
+
+export default Footer;
diff --git a/frontend/src/components/layout/PageHeader.jsx b/frontend/src/components/layout/PageHeader.jsx
new file mode 100644
index 0000000..9b9cbe7
--- /dev/null
+++ b/frontend/src/components/layout/PageHeader.jsx
@@ -0,0 +1,29 @@
+import { cn } from "../../lib/format.js";
+
+const PageHeader = ({ eyebrow, title, description, actions, className = "" }) => (
+
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {actions &&
{actions}
}
+
+);
+
+export default PageHeader;
diff --git a/frontend/src/components/layout/TopBar.jsx b/frontend/src/components/layout/TopBar.jsx
new file mode 100644
index 0000000..bc59515
--- /dev/null
+++ b/frontend/src/components/layout/TopBar.jsx
@@ -0,0 +1,95 @@
+import { Link, NavLink, useNavigate } from "react-router-dom";
+import toast from "react-hot-toast";
+import { useAuth } from "../../hooks/useAuth.js";
+import { logoutRequest } from "../../api/auth.api.js";
+import { cn } from "../../lib/format.js";
+
+const navItemClass = ({ isActive }) =>
+ cn(
+ "border-2 border-transparent px-3 py-1 font-mono text-xs uppercase tracking-[0.3em]",
+ isActive ? "border-black bg-yellow-400" : "hover:border-black hover:bg-white",
+ );
+
+const TopBar = () => {
+ const { authUser, signOut } = useAuth();
+ const navigate = useNavigate();
+
+ const handleLogout = async () => {
+ try {
+ await logoutRequest();
+ signOut();
+ toast.success("Signed out");
+ navigate("/");
+ } catch (error) {
+ toast.error(error.message || "Failed to sign out");
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default TopBar;
diff --git a/frontend/src/components/solutions/SolutionForm.jsx b/frontend/src/components/solutions/SolutionForm.jsx
new file mode 100644
index 0000000..74563f7
--- /dev/null
+++ b/frontend/src/components/solutions/SolutionForm.jsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import toast from "react-hot-toast";
+
+import Panel, { PanelBody, PanelHeader, PanelTitle } from "../ui/Panel.jsx";
+import Textarea from "../ui/Textarea.jsx";
+import Button from "../ui/Button.jsx";
+import { createSolution } from "../../api/solutions.api.js";
+
+const SolutionForm = ({ issueId, onSubmitted }) => {
+ const [description, setDescription] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ if (!description.trim()) {
+ toast.error("Solution cannot be empty");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const created = await createSolution(issueId, { description });
+ toast.success("Solution posted");
+ setDescription("");
+ onSubmitted?.(created);
+ } catch (error) {
+ toast.error(error.message || "Failed to submit solution");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ Solution · admin only
+
+ resolve this ticket
+
+
+
+
+
+
+ );
+};
+
+export default SolutionForm;
diff --git a/frontend/src/components/solutions/SolutionPanel.jsx b/frontend/src/components/solutions/SolutionPanel.jsx
new file mode 100644
index 0000000..61df1e6
--- /dev/null
+++ b/frontend/src/components/solutions/SolutionPanel.jsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from "react";
+import toast from "react-hot-toast";
+
+import Panel, { PanelBody, PanelHeader, PanelTitle } from "../ui/Panel.jsx";
+import Spinner from "../ui/Spinner.jsx";
+import { useAuth } from "../../hooks/useAuth.js";
+import { getSolution } from "../../api/solutions.api.js";
+import { formatDate } from "../../lib/format.js";
+import SolutionForm from "./SolutionForm.jsx";
+
+const SolutionPanel = ({ issueId }) => {
+ const { authUser } = useAuth();
+ const [solution, setSolution] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let active = true;
+ setLoading(true);
+ getSolution(issueId)
+ .then((data) => {
+ if (active) setSolution(data?.description ? data : null);
+ })
+ .catch((err) => toast.error(err.message || "Failed to load solution"))
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [issueId]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (solution) {
+ return (
+
+
+ Resolved
+
+ {formatDate(solution.created_at)}
+
+
+
+
+ {solution.description}
+
+
+
+ );
+ }
+
+ if (authUser?.role === "admin") {
+ return ;
+ }
+
+ return (
+
+
+ Solution
+
+ pending admin
+
+
+
+
+ An administrator has not posted a resolution yet.
+
+
+
+ );
+};
+
+export default SolutionPanel;
diff --git a/frontend/src/components/ui/Button.jsx b/frontend/src/components/ui/Button.jsx
new file mode 100644
index 0000000..bcb59be
--- /dev/null
+++ b/frontend/src/components/ui/Button.jsx
@@ -0,0 +1,38 @@
+import { cn } from "../../lib/format.js";
+
+const BASE =
+ "inline-flex items-center justify-center gap-2 border-2 border-black px-5 py-2.5 font-semibold uppercase tracking-wider text-sm transition-all duration-150 active:translate-x-[3px] active:translate-y-[3px] active:shadow-none disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none disabled:translate-x-0 disabled:translate-y-0";
+
+const VARIANTS = {
+ primary:
+ "bg-yellow-400 text-black shadow-brutal hover:-translate-x-[2px] hover:-translate-y-[2px] hover:shadow-brutal-lg",
+ dark: "bg-black text-white shadow-brutal-white hover:-translate-x-[2px] hover:-translate-y-[2px]",
+ ghost: "bg-transparent text-black shadow-brutal-sm hover:bg-black hover:text-white",
+ danger:
+ "bg-red-500 text-white shadow-brutal hover:-translate-x-[2px] hover:-translate-y-[2px]",
+};
+
+const SIZES = {
+ sm: "px-3 py-1.5 text-xs",
+ md: "",
+ lg: "px-7 py-3.5 text-base",
+};
+
+const Button = ({
+ // eslint-disable-next-line no-unused-vars
+ as: Tag = "button",
+ variant = "primary",
+ size = "md",
+ className = "",
+ children,
+ ...rest
+}) => (
+
+ {children}
+
+);
+
+export default Button;
diff --git a/frontend/src/components/ui/EmptyState.jsx b/frontend/src/components/ui/EmptyState.jsx
new file mode 100644
index 0000000..9a1fd90
--- /dev/null
+++ b/frontend/src/components/ui/EmptyState.jsx
@@ -0,0 +1,19 @@
+import { cn } from "../../lib/format.js";
+
+const EmptyState = ({ title, description, action, className = "" }) => (
+
+
+ // empty
+
+
{title}
+ {description &&
{description}
}
+ {action}
+
+);
+
+export default EmptyState;
diff --git a/frontend/src/components/ui/Input.jsx b/frontend/src/components/ui/Input.jsx
new file mode 100644
index 0000000..bd8632e
--- /dev/null
+++ b/frontend/src/components/ui/Input.jsx
@@ -0,0 +1,43 @@
+import { forwardRef } from "react";
+import { cn } from "../../lib/format.js";
+
+const Input = forwardRef(function Input(
+ { label, hint, error, className = "", id, ...rest },
+ ref,
+) {
+ const inputId = id || rest.name;
+ return (
+
+ {label && (
+
+ )}
+
+ {(hint || error) && (
+
+ {error || hint}
+
+ )}
+
+ );
+});
+
+export default Input;
diff --git a/frontend/src/components/ui/Panel.jsx b/frontend/src/components/ui/Panel.jsx
new file mode 100644
index 0000000..010d332
--- /dev/null
+++ b/frontend/src/components/ui/Panel.jsx
@@ -0,0 +1,42 @@
+import { cn } from "../../lib/format.js";
+
+const Panel = ({ className = "", elevated = true, children, ...rest }) => (
+
+ {children}
+
+);
+
+export const PanelHeader = ({ className = "", children }) => (
+
+ {children}
+
+);
+
+export const PanelTitle = ({ children, className = "" }) => (
+
+ {children}
+
+);
+
+export const PanelBody = ({ className = "", children }) => (
+ {children}
+);
+
+export default Panel;
diff --git a/frontend/src/components/ui/Spinner.jsx b/frontend/src/components/ui/Spinner.jsx
new file mode 100644
index 0000000..205aabf
--- /dev/null
+++ b/frontend/src/components/ui/Spinner.jsx
@@ -0,0 +1,10 @@
+import { cn } from "../../lib/format.js";
+
+const Spinner = ({ label = "Loading", className = "" }) => (
+
+
+ {label}
+
+);
+
+export default Spinner;
diff --git a/frontend/src/components/ui/StatTile.jsx b/frontend/src/components/ui/StatTile.jsx
new file mode 100644
index 0000000..ffabff6
--- /dev/null
+++ b/frontend/src/components/ui/StatTile.jsx
@@ -0,0 +1,18 @@
+import { cn } from "../../lib/format.js";
+
+const StatTile = ({ label, value, accent = "bg-yellow-400", className = "" }) => (
+
+
+
+ {label}
+
+ {value}
+
+);
+
+export default StatTile;
diff --git a/frontend/src/components/ui/StatusTag.jsx b/frontend/src/components/ui/StatusTag.jsx
new file mode 100644
index 0000000..fa3f2d3
--- /dev/null
+++ b/frontend/src/components/ui/StatusTag.jsx
@@ -0,0 +1,23 @@
+import { cn } from "../../lib/format.js";
+
+const STATUS_STYLES = {
+ open: "bg-emerald-400 text-black",
+ "in-progress": "bg-yellow-400 text-black",
+ resolved: "bg-sky-400 text-black",
+ closed: "bg-black text-white",
+};
+
+const StatusTag = ({ status = "open", className = "" }) => (
+
+
+ {status}
+
+);
+
+export default StatusTag;
diff --git a/frontend/src/components/ui/Textarea.jsx b/frontend/src/components/ui/Textarea.jsx
new file mode 100644
index 0000000..2f9c400
--- /dev/null
+++ b/frontend/src/components/ui/Textarea.jsx
@@ -0,0 +1,44 @@
+import { forwardRef } from "react";
+import { cn } from "../../lib/format.js";
+
+const Textarea = forwardRef(function Textarea(
+ { label, hint, error, className = "", id, rows = 6, ...rest },
+ ref,
+) {
+ const inputId = id || rest.name;
+ return (
+
+ {label && (
+
+ )}
+
+ {(hint || error) && (
+
+ {error || hint}
+
+ )}
+
+ );
+});
+
+export default Textarea;
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx
new file mode 100644
index 0000000..160369c
--- /dev/null
+++ b/frontend/src/context/AuthContext.jsx
@@ -0,0 +1,48 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+// eslint-disable-next-line no-unused-vars
+import { authContext } from "./authContext.js";
+
+const STORAGE_KEY = "issuetrack.user";
+
+const readStoredUser = () => {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+};
+
+function AuthProvider({ children }) {
+ const [authUser, setAuthUserState] = useState(readStoredUser);
+
+ const setAuthUser = useCallback((user) => {
+ setAuthUserState(user);
+ if (user) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
+ } else {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }, []);
+
+ const signOut = useCallback(() => setAuthUser(null), [setAuthUser]);
+
+ useEffect(() => {
+ const onStorage = (event) => {
+ if (event.key === STORAGE_KEY) {
+ setAuthUserState(readStoredUser());
+ }
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, []);
+
+ const value = useMemo(
+ () => ({ authUser, setAuthUser, signOut }),
+ [authUser, setAuthUser, signOut],
+ );
+
+ return {children};
+}
+
+export default AuthProvider;
diff --git a/frontend/src/context/authContext.js b/frontend/src/context/authContext.js
new file mode 100644
index 0000000..58d9ed3
--- /dev/null
+++ b/frontend/src/context/authContext.js
@@ -0,0 +1,7 @@
+import { createContext } from "react";
+
+export const authContext = createContext({
+ authUser: null,
+ setAuthUser: () => {},
+ signOut: () => {},
+});
diff --git a/frontend/src/context/authContext.jsx b/frontend/src/context/authContext.jsx
deleted file mode 100644
index eeca406..0000000
--- a/frontend/src/context/authContext.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { createContext, useState } from "react";
-
-function getStoredUser() {
- try{
- const user = JSON.parse(localStorage.getItem("user")) || null;
- return user;
- }catch(error){
- return null;
- }
-}
-
-const authContext = createContext();
-
-function AuthProvider({children}) {
- const [authUser, setAuthUser] = useState(getStoredUser);
- return(
-
- {children}
-
- )
-}
-
-export default AuthProvider;
-export {authContext};
\ No newline at end of file
diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js
new file mode 100644
index 0000000..4d4f1c3
--- /dev/null
+++ b/frontend/src/hooks/useAuth.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { authContext } from "../context/authContext.js";
+
+export const useAuth = () => useContext(authContext);
diff --git a/frontend/src/index.css b/frontend/src/index.css
deleted file mode 100644
index a14b751..0000000
--- a/frontend/src/index.css
+++ /dev/null
@@ -1,7 +0,0 @@
-@import "tailwindcss";
-body{
- font-family: "Outfit";
-}
-*{
- box-sizing: border-box;
-}
\ No newline at end of file
diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js
new file mode 100644
index 0000000..303b250
--- /dev/null
+++ b/frontend/src/lib/format.js
@@ -0,0 +1,11 @@
+export const formatDate = (value) => {
+ if (!value) return "—";
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return "—";
+ return d.toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+};
+
+export const cn = (...parts) => parts.filter(Boolean).join(" ");
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 7d9de67..6adab9f 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,16 +1,17 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.jsx'
-import { BrowserRouter } from 'react-router-dom'
-import AuthProvider from './context/authContext.jsx'
-
-createRoot(document.getElementById('root')).render(
-
-
-
-
-
-
- ,
-)
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+
+import "./styles/index.css";
+import App from "./App.jsx";
+import AuthProvider from "./context/AuthContext.jsx";
+
+createRoot(document.getElementById("root")).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
new file mode 100644
index 0000000..5c5197d
--- /dev/null
+++ b/frontend/src/pages/Home.jsx
@@ -0,0 +1,151 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import toast from "react-hot-toast";
+import { HiArrowRight } from "react-icons/hi2";
+
+import AppShell from "../components/layout/AppShell.jsx";
+import StatTile from "../components/ui/StatTile.jsx";
+import Button from "../components/ui/Button.jsx";
+import { listIssues } from "../api/issues.api.js";
+import { useAuth } from "../hooks/useAuth.js";
+
+const Home = () => {
+ const { authUser } = useAuth();
+ const [stats, setStats] = useState({ total: 0, open: 0, resolved: 0, progress: 0 });
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let active = true;
+ listIssues()
+ .then((issues) => {
+ if (!active) return;
+ const next = { total: issues.length, open: 0, resolved: 0, progress: 0 };
+ for (const issue of issues) {
+ if (issue.status === "open") next.open += 1;
+ if (issue.status === "resolved") next.resolved += 1;
+ if (issue.status === "in-progress") next.progress += 1;
+ }
+ setStats(next);
+ })
+ .catch((err) => toast.error(err.message || "Failed to load stats"))
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+ Open source · PERN · v1.0
+
+
+ Track every issue.
+
+ Ship every fix.
+
+
+ A deliberately minimal helpdesk for institutions. Report a
+ problem, watch it move through the pipeline, and let the admins
+ close the loop — no tickets lost in email threads.
+
+
+
+ {authUser ? (
+
+ ) : (
+
+
+
+
+ )}
+
+ no credit card · no setup · just issues
+
+
+
+
+
+
+
+
+
+
+ // signals
+
+
+ Live stats
+
+
+
+ {loading ? "syncing…" : "synced"}
+
+
+
+
+
+
+
+
+
+
+
+
+ {[
+ {
+ k: "01",
+ title: "Report fast",
+ body: "Authenticated users file issues in seconds with a single form.",
+ },
+ {
+ k: "02",
+ title: "Route clearly",
+ body: "Admins see every report in a single, chronological queue.",
+ },
+ {
+ k: "03",
+ title: "Resolve publicly",
+ body: "Solutions are posted alongside the issue for future reference.",
+ },
+ ].map((item) => (
+
+
+ {item.k}
+
+
+ {item.title}
+
+
{item.body}
+
+ ))}
+
+
+
+ );
+};
+
+export default Home;
diff --git a/frontend/src/pages/Issue.jsx b/frontend/src/pages/Issue.jsx
new file mode 100644
index 0000000..78063e1
--- /dev/null
+++ b/frontend/src/pages/Issue.jsx
@@ -0,0 +1,27 @@
+import { Link, useParams } from "react-router-dom";
+import { HiArrowLeft } from "react-icons/hi2";
+
+import AppShell from "../components/layout/AppShell.jsx";
+import IssueDetails from "../components/issues/IssueDetails.jsx";
+import SolutionPanel from "../components/solutions/SolutionPanel.jsx";
+
+const Issue = () => {
+ const { id } = useParams();
+ return (
+
+
+
+
+ back to queue
+
+
+
+
+
+ );
+};
+
+export default Issue;
diff --git a/frontend/src/pages/Issues.jsx b/frontend/src/pages/Issues.jsx
new file mode 100644
index 0000000..5155ab0
--- /dev/null
+++ b/frontend/src/pages/Issues.jsx
@@ -0,0 +1,140 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import toast from "react-hot-toast";
+
+import AppShell from "../components/layout/AppShell.jsx";
+import PageHeader from "../components/layout/PageHeader.jsx";
+import IssueForm from "../components/issues/IssueForm.jsx";
+import IssueCard from "../components/issues/IssueCard.jsx";
+import Spinner from "../components/ui/Spinner.jsx";
+import EmptyState from "../components/ui/EmptyState.jsx";
+import StatTile from "../components/ui/StatTile.jsx";
+import { listIssues } from "../api/issues.api.js";
+import { cn } from "../lib/format.js";
+
+const STATUS_FILTERS = [
+ { value: "all", label: "All" },
+ { value: "open", label: "Open" },
+ { value: "in-progress", label: "In progress" },
+ { value: "resolved", label: "Resolved" },
+];
+
+const Issues = () => {
+ const [issues, setIssues] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [filter, setFilter] = useState("all");
+
+ const load = useCallback(() => {
+ setLoading(true);
+ return listIssues()
+ .then((data) => setIssues(data))
+ .catch((err) => {
+ setError(err.message || "Failed to load issues");
+ toast.error(err.message || "Failed to load issues");
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const stats = useMemo(() => {
+ const next = { total: issues.length, open: 0, progress: 0, resolved: 0 };
+ for (const issue of issues) {
+ if (issue.status === "open") next.open += 1;
+ if (issue.status === "in-progress") next.progress += 1;
+ if (issue.status === "resolved") next.resolved += 1;
+ }
+ return next;
+ }, [issues]);
+
+ const filtered = useMemo(() => {
+ if (filter === "all") return issues;
+ return issues.filter((issue) => issue.status === filter);
+ }, [issues, filter]);
+
+ const handleIssueCreated = () => load();
+ const handleIssueDeleted = (id) =>
+ setIssues((prev) => prev.filter((issue) => issue.id !== id));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Filter
+
+
+ {STATUS_FILTERS.map((option) => (
+
+ ))}
+
+
+
+ {filtered.length} shown
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+ {error}
+
+ ) : filtered.length === 0 ? (
+
+ ) : (
+
+ {filtered.map((issue) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+export default Issues;
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
new file mode 100644
index 0000000..701c5e6
--- /dev/null
+++ b/frontend/src/pages/Login.jsx
@@ -0,0 +1,109 @@
+import { useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import toast from "react-hot-toast";
+
+import AppShell from "../components/layout/AppShell.jsx";
+import Panel, { PanelBody, PanelHeader, PanelTitle } from "../components/ui/Panel.jsx";
+import Input from "../components/ui/Input.jsx";
+import Button from "../components/ui/Button.jsx";
+import { loginRequest } from "../api/auth.api.js";
+import { useAuth } from "../hooks/useAuth.js";
+
+const Login = () => {
+ const { setAuthUser } = useAuth();
+ const navigate = useNavigate();
+ const [form, setForm] = useState({ admission_number: "", password: "" });
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleChange = (event) => {
+ const { name, value } = event.target;
+ setForm((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ setError(null);
+ setSubmitting(true);
+ try {
+ const user = await loginRequest(form);
+ setAuthUser(user);
+ toast.success("Welcome back");
+ navigate("/issues");
+ } catch (err) {
+ setError(err.message || "Unable to sign in");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ // authenticate
+
+
+ Sign in.
+
+
+ Use your admission number and password to access the issue dashboard.
+
+
+
+ New here?
+
+
+
+ Create an account to start reporting issues.
+
+
+
+
+
+
+
+
+ Login · /api/auth/login
+
+
+
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx
new file mode 100644
index 0000000..4739e65
--- /dev/null
+++ b/frontend/src/pages/NotFound.jsx
@@ -0,0 +1,24 @@
+import { Link } from "react-router-dom";
+import AppShell from "../components/layout/AppShell.jsx";
+import Button from "../components/ui/Button.jsx";
+
+const NotFound = () => (
+
+
+
+ // 404
+
+
+ Nothing here.
+
+
+ The page you are looking for does not exist, or has been moved.
+
+
+
+
+);
+
+export default NotFound;
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx
new file mode 100644
index 0000000..97c40e5
--- /dev/null
+++ b/frontend/src/pages/Register.jsx
@@ -0,0 +1,183 @@
+import { useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import toast from "react-hot-toast";
+
+import AppShell from "../components/layout/AppShell.jsx";
+import Panel, { PanelBody, PanelHeader, PanelTitle } from "../components/ui/Panel.jsx";
+import Input from "../components/ui/Input.jsx";
+import Button from "../components/ui/Button.jsx";
+import { registerRequest } from "../api/auth.api.js";
+import { useAuth } from "../hooks/useAuth.js";
+import { cn } from "../lib/format.js";
+
+const initialState = {
+ name: "",
+ admission_number: "",
+ password: "",
+ confirmPassword: "",
+ gender: "",
+};
+
+const GenderOption = ({ value, label, selected, onSelect }) => (
+
+);
+
+const Register = () => {
+ const { setAuthUser } = useAuth();
+ const navigate = useNavigate();
+ const [form, setForm] = useState(initialState);
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleChange = (event) => {
+ const { name, value } = event.target;
+ setForm((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ setError(null);
+
+ if (form.password !== form.confirmPassword) {
+ setError("Passwords do not match");
+ return;
+ }
+ if (!form.gender) {
+ setError("Please select a gender");
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const user = await registerRequest(form);
+ setAuthUser(user);
+ toast.success("Account created");
+ navigate("/issues");
+ } catch (err) {
+ setError(err.message || "Registration failed");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ // onboarding
+
+
+ Create account.
+
+
+ Registration is restricted to valid institutional admission numbers.
+ Your profile picture is generated automatically.
+
+
+
+ Already registered?
+
+
+
+ Log in to jump back into your dashboard.
+
+
+
+
+
+
+
+
+ Register · /api/auth/register
+
+
+
+
+
+
+
+ );
+};
+
+export default Register;
diff --git a/frontend/src/pages/auth/GenderCheckbox.jsx b/frontend/src/pages/auth/GenderCheckbox.jsx
deleted file mode 100644
index 235f4ce..0000000
--- a/frontend/src/pages/auth/GenderCheckbox.jsx
+++ /dev/null
@@ -1,21 +0,0 @@
-const GenderCheckbox = ({onCheckBoxChange, selectedGender}) => {
- return (
-
- );
-};
-export default GenderCheckbox;
\ No newline at end of file
diff --git a/frontend/src/pages/auth/Login.jsx b/frontend/src/pages/auth/Login.jsx
deleted file mode 100644
index c20b658..0000000
--- a/frontend/src/pages/auth/Login.jsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import React, { useContext, useState } from 'react';
-import toast from 'react-hot-toast';
-import { Link } from 'react-router-dom';
-import { authContext } from '../../context/authContext';
-
-const Login = () => {
-
- const { setAuthUser } = useContext(authContext);
-
- const [form, setForm] = useState({
- admission_number: '',
- password: ''
- });
-
- const [error, setError] = useState('');
-
- const handleChange = (e) => {
- setForm({ ...form, [e.target.name]: e.target.value });
- };
-
- const handleSubmit = async (e) => {
- e.preventDefault();
- try{
- const res = await fetch ("/api/auth/login", {
- method: "POST",
- headers: {"Content-Type" : "application/json"},
- body: JSON.stringify(form)
- });
- const data = await res.json();
- if(data.error){
- throw new Error(data.error);
- }
- console.log(data);
- setAuthUser(data);
- localStorage.setItem("user", JSON.stringify(data));
- toast.success("Logged In Successfully")
- }catch(error){
- setError(error.message);
- }
- };
-
- return (
-
-);
-};
-
-export default Login;
diff --git a/frontend/src/pages/auth/Register.jsx b/frontend/src/pages/auth/Register.jsx
deleted file mode 100644
index 8e12ef9..0000000
--- a/frontend/src/pages/auth/Register.jsx
+++ /dev/null
@@ -1,201 +0,0 @@
-import React, { useContext, useState } from 'react';
-import { Link, Navigate } from 'react-router-dom';
-import GenderCheckbox from './GenderCheckbox';
-import toast from 'react-hot-toast';
-import { authContext } from '../../context/authContext';
-
-
-const Register = () => {
-
- const { setAuthUser } = useContext(authContext);
-
- const [formData, setFormData] = useState({
- name: '',
- admission_number: '',
- password: '',
- confirmPassword: '',
- gender: ''
- });
-
- const [error, setError] = useState('');
-
- const handleChange = (e) => {
- setFormData({ ...formData, [e.target.name]: e.target.value });
- };
- const handleCheckBoxChange = (gender) => {
- setFormData({...formData, gender});
- }
- const handleSubmit = async (e) => {
- e.preventDefault();
- const { name, admission_number, password, confirmPassword, gender } = formData;
-
- if (password !== confirmPassword) {
- setError('Passwords do not match');
- return;
- }
- try {
- const res = await fetch('/api/auth/register', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ name, admission_number, password, confirmPassword, gender }),
- });
- const data = await res.json();
- if(data.error){
- setError(data.error || 'Registration failed');
- throw new Error(data.error);
- }
- localStorage.setItem("user", JSON.stringify(data));
- toast.success("Welcome!");
- console.log(data);
- setAuthUser(data);
- } catch (err) {
- setError('Server error');
- console.error(err);
- }
- };
-
- return (
- //
-
-
- );
-};
-
-export default Register;
diff --git a/frontend/src/pages/home/Home.jsx b/frontend/src/pages/home/Home.jsx
deleted file mode 100644
index efeb644..0000000
--- a/frontend/src/pages/home/Home.jsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import React, { useEffect, useState } from "react";
-import toast from "react-hot-toast";
-import { Link } from "react-router-dom";
-import "../../index.css";
-
-function App() {
- const [stats, setStats] = useState({
- total: 0,
- open: 0,
- resolved: 0,
- });
-
- useEffect( () => {
- const getData = async () => {
- try{
- const res = await fetch("/api/issues/");
- const data = await res.json();
- if(data.error){
- throw new Error(data.error);
- }
- let currentTotal = data.length, currentOpen = 0, currentResolved = 0;
- data.map((issue) => {
- if(issue.status === 'open') currentOpen++;
- if(issue.status === 'resolved') currentResolved++;
- })
- console.log(data);
- setStats({
- total: currentTotal,
- open: currentOpen,
- resolved: currentResolved,
- });
- } catch(error){
- toast.error(error.message);
- }
- }
- getData();
- }, []);
-
- return (
-
-
- {/* Header */}
-
-
- {/* Statistics Cards */}
-
-
-
- Total Issues
-
-
{stats.total}
-
-
-
- Open Issues
-
-
{stats.open}
-
-
-
- Resolved Issues
-
-
- {stats.resolved}
-
-
-
-
-
-
- Register
-
-
- Login
-
-
-
-
-
- );
-}
-
-export default App;
diff --git a/frontend/src/pages/issue/Issue.jsx b/frontend/src/pages/issue/Issue.jsx
deleted file mode 100644
index 13d202a..0000000
--- a/frontend/src/pages/issue/Issue.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import IssueDetails from '../../components/IssueDeatails';
-import Solution from '../../components/Solution';
-import { useParams } from 'react-router-dom';
-
-
-const Issue = () => {
- const { id } = useParams();
-
- return (
-
-
-
-
- )
-};
-
-export default Issue;
diff --git a/frontend/src/pages/issues/Issues.jsx b/frontend/src/pages/issues/Issues.jsx
deleted file mode 100644
index 8092eb2..0000000
--- a/frontend/src/pages/issues/Issues.jsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import React, { useContext, useEffect, useState } from 'react';
-import IssueCard from '../../components/IssueCard';
-import LogoutButton from '../../components/LogoutButton';
-import { Navigate } from 'react-router-dom';
-import { authContext } from '../../context/authContext';
-
-const Issues = () => {
- const { authUser } = useContext(authContext);
-
- const [issues, setIssues] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const [title, setTitle] = useState('');
- const [description, setDescription] = useState('');
- // const [priority, setPriority] = useState('low');
- const fetchIssues = async () => {
- try {
- setLoading(true);
- const res = await fetch("/api/issues");
- const data = await res.json();
- if(data.error){
- throw new Error(data.error);
- }
- setIssues(data);
-
- } catch (err) {
- setError('Failed to fetch issues.');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
- useEffect(() => {
- fetchIssues();
- }, []);
-
- const handleSubmit = async (e) => {
- e.preventDefault();
- const user_id = authUser.id;
- try {
- const res = await fetch('/api/issues', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title, description, user_id}),
- });
- const data = await res.json();
- if (data.error) throw new Error(data.error);
- setTitle('');
- setDescription('');
- fetchIssues();
- } catch (err) {
- alert('Failed to raise issue.');
- console.error(err);
- }
- };
-
- if (loading) return Loading issues...
;
- if (error) return {error}
;
-
-return (
-
-
-
Issue Tracker
-
-
-
- {/* Raise Issue Form */}
-
- {/* Issue List */}
-
-
Recent Issues
-
-
- {issues.length === 0 ? (
-
No issues found.
- ) : (
- issues.map((issue) => (
-
- ))
- )}
-
-
- );
-};
-
-export default Issues;
diff --git a/frontend/src/routes/AppRoutes.jsx b/frontend/src/routes/AppRoutes.jsx
new file mode 100644
index 0000000..137e516
--- /dev/null
+++ b/frontend/src/routes/AppRoutes.jsx
@@ -0,0 +1,52 @@
+import { Route, Routes } from "react-router-dom";
+
+import Home from "../pages/Home.jsx";
+import Login from "../pages/Login.jsx";
+import Register from "../pages/Register.jsx";
+import Issues from "../pages/Issues.jsx";
+import Issue from "../pages/Issue.jsx";
+import NotFound from "../pages/NotFound.jsx";
+
+import ProtectedRoute from "./ProtectedRoute.jsx";
+import GuestRoute from "./GuestRoute.jsx";
+
+const AppRoutes = () => (
+
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+
+);
+
+export default AppRoutes;
diff --git a/frontend/src/routes/GuestRoute.jsx b/frontend/src/routes/GuestRoute.jsx
new file mode 100644
index 0000000..cc745fb
--- /dev/null
+++ b/frontend/src/routes/GuestRoute.jsx
@@ -0,0 +1,10 @@
+import { Navigate } from "react-router-dom";
+import { useAuth } from "../hooks/useAuth.js";
+
+const GuestRoute = ({ children }) => {
+ const { authUser } = useAuth();
+ if (authUser) return ;
+ return children;
+};
+
+export default GuestRoute;
diff --git a/frontend/src/routes/ProtectedRoute.jsx b/frontend/src/routes/ProtectedRoute.jsx
new file mode 100644
index 0000000..c29f5af
--- /dev/null
+++ b/frontend/src/routes/ProtectedRoute.jsx
@@ -0,0 +1,13 @@
+import { Navigate, useLocation } from "react-router-dom";
+import { useAuth } from "../hooks/useAuth.js";
+
+const ProtectedRoute = ({ children }) => {
+ const { authUser } = useAuth();
+ const location = useLocation();
+ if (!authUser) {
+ return ;
+ }
+ return children;
+};
+
+export default ProtectedRoute;
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
new file mode 100644
index 0000000..25fbdb9
--- /dev/null
+++ b/frontend/src/styles/index.css
@@ -0,0 +1,73 @@
+@import "tailwindcss";
+
+@layer base {
+ :root {
+ --color-ink: #0a0a0a;
+ --color-paper: #f5f5f4;
+ --color-accent: #facc15;
+ --color-accent-ink: #111827;
+ --color-danger: #ef4444;
+ --color-positive: #10b981;
+ --color-muted: #737373;
+ }
+
+ html,
+ body,
+ #root {
+ height: 100%;
+ }
+
+ body {
+ font-family: "Outfit", ui-sans-serif, system-ui, sans-serif;
+ background-color: var(--color-paper);
+ color: var(--color-ink);
+ -webkit-font-smoothing: antialiased;
+ }
+
+ *,
+ *::before,
+ *::after {
+ box-sizing: border-box;
+ border-radius: 0 !important;
+ }
+
+ button {
+ cursor: pointer;
+ }
+
+ :focus-visible {
+ outline: 3px solid var(--color-accent);
+ outline-offset: 2px;
+ }
+
+ .font-mono {
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ }
+}
+
+@layer utilities {
+ .shadow-brutal {
+ box-shadow: 6px 6px 0 0 var(--color-ink);
+ }
+ .shadow-brutal-sm {
+ box-shadow: 3px 3px 0 0 var(--color-ink);
+ }
+ .shadow-brutal-lg {
+ box-shadow: 10px 10px 0 0 var(--color-ink);
+ }
+ .shadow-brutal-white {
+ box-shadow: 6px 6px 0 0 var(--color-paper);
+ }
+ .grid-paper {
+ background-image:
+ linear-gradient(to right, rgba(0, 0, 0, 0.06) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(0, 0, 0, 0.06) 1px, transparent 1px);
+ background-size: 32px 32px;
+ }
+ .grid-paper-dark {
+ background-image:
+ linear-gradient(to right, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
+ background-size: 32px 32px;
+ }
+}
diff --git a/package.json b/package.json
index 5e8e1dd..738a38b 100644
--- a/package.json
+++ b/package.json
@@ -1,16 +1,24 @@
{
"name": "issuetracker",
"version": "1.0.0",
- "main": "backend/server.js",
+ "description": "IssueTrack — PERN-stack institutional issue tracking platform.",
+ "main": "backend/src/server.js",
"type": "module",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1",
- "server": "nodemon backend/server.js"
+ "start": "node backend/src/server.js",
+ "server": "nodemon backend/src/server.js",
+ "dev": "nodemon backend/src/server.js",
+ "test": "echo \"no tests yet\" && exit 0"
},
- "keywords": [],
+ "keywords": [
+ "pern",
+ "issue-tracker",
+ "express",
+ "postgres",
+ "react"
+ ],
"author": "",
"license": "ISC",
- "description": "",
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
@@ -18,5 +26,8 @@
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.14.1"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.9"
}
}