diff --git a/.gitignore b/.gitignore index 48bbc48..9e42270 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ web_modules/ .env.test.local .env.production.local .env.local +.env* # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/nextstep-backend/src/app.ts b/nextstep-backend/src/app.ts index 5e449cb..30aef16 100644 --- a/nextstep-backend/src/app.ts +++ b/nextstep-backend/src/app.ts @@ -62,6 +62,7 @@ app.use(authenticateToken.unless({ { url: '/comment', methods: ['GET'] }, { url: '/post', methods: ['GET'] }, // Allow GET to /post { url: /^\/resource\/image\/[^\/]+$/, methods: ['GET'] }, // Allow GET to /resource/image/{anything} + { url: /^\/resource\/file\/[^\/]+$/, methods: ['GET'] }, // Allow GET to /resource/image/{anything} ] })); diff --git a/nextstep-backend/src/config/config.ts b/nextstep-backend/src/config/config.ts index 536cb3f..eeec90a 100644 --- a/nextstep-backend/src/config/config.ts +++ b/nextstep-backend/src/config/config.ts @@ -15,6 +15,8 @@ export const config = { refresh_token_secret: () => process.env.REFRESH_TOKEN_SECRET || 'secret' }, resources: { + filesDirectoryPath: () => 'resources/files', + fileMaxSize: () => 10 * 1024 * 1024, // Max file size: 10MB imagesDirectoryPath: () => 'resources/images', imageMaxSize: () => 10 * 1024 * 1024, // Max file size: 10MB resumesDirectoryPath: () => 'resources/resumes', diff --git a/nextstep-backend/src/controllers/resources_controller.ts b/nextstep-backend/src/controllers/resources_controller.ts index 7e62bcd..b01f10d 100644 --- a/nextstep-backend/src/controllers/resources_controller.ts +++ b/nextstep-backend/src/controllers/resources_controller.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { config } from '../config/config'; import fs from 'fs'; import path from 'path'; -import { uploadResume, uploadImage } from '../services/resources_service'; +import { uploadResume, uploadImage, uploadFile } from '../services/resources_service'; import multer from 'multer'; import {CustomRequest} from "types/customRequest"; import {updateUserById} from "../services/users_service"; @@ -37,6 +37,19 @@ const createImageResource = async (req: Request, res: Response) => { } }; +const createFileResource = async (req: Request, res: Response) => { + try { + const filename = await uploadFile(req); + return res.status(201).send(filename); + } catch (error) { + if (error instanceof multer.MulterError || error instanceof TypeError) { + return res.status(400).send({ message: error.message }); + } else { + handleError(error, res); + } + } +}; + const getImageResource = async (req: Request, res: Response) => { try { const { filename } = req.params; @@ -52,9 +65,27 @@ const getImageResource = async (req: Request, res: Response) => { } }; +const getFileResource = async (req: Request, res: Response) => { + try { + const { filename } = req.params; + const filePath = path.resolve(config.resources.filesDirectoryPath(), filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).send('File not found'); + } + + // res.sendFile(filePath); + return res.download(filePath); + } catch (error) { + handleError(error, res); + } +}; + const createResumeResource = async (req: Request, res: Response) => { try { - const resumeFilename = await uploadResume(req); + const resumeFilename = await uploadResume(req); + + return res.status(201).send(resumeFilename); } catch (error) { if (error instanceof multer.MulterError || error instanceof TypeError) { @@ -83,7 +114,9 @@ const getResumeResource = async (req: Request, res: Response) => { export default { createUserImageResource, createImageResource, + createFileResource, getImageResource, + getFileResource, getResumeResource, createResumeResource }; \ No newline at end of file diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 258f8b6..ba44b43 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -2,11 +2,20 @@ import { Request, Response } from 'express'; import { config } from '../config/config'; import fs from 'fs'; import path from 'path'; -import { scoreResume, streamScoreResume, parseResumeFields } from '../services/resume_service'; +import { scoreResume, streamScoreResume, parseResumeFields, + saveParsedResume, getResumeByOwner, updateResume } from '../services/resume_service'; import multer from 'multer'; +import {getResumeBuffer, resumeExists} from '../services/resources_service'; import { CustomRequest } from "types/customRequest"; import { handleError } from "../utils/handle_error"; +// Simple in-memory cache: { [key: string]: { scoreAndFeedback, timestamp } } +const resumeScoreCache: Record = {}; +const CACHE_TTL_MS = 24* 60 * 60 * 1000; // 24 hour + +const getCacheKey = (filename: string, jobDescription?: string) => + `${filename}::${jobDescription || ''}`; + const getResumeScore = async (req: Request, res: Response) => { try { const { filename } = req.params; @@ -17,7 +26,14 @@ const getResumeScore = async (req: Request, res: Response) => { return res.status(404).send('Resume not found'); } + const cacheKey = getCacheKey(filename, jobDescription); + const cached = resumeScoreCache[cacheKey]; + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return res.status(200).send(cached.data); + } + const scoreAndFeedback = await scoreResume(resumePath, jobDescription); + resumeScoreCache[cacheKey] = { data: scoreAndFeedback, timestamp: Date.now() }; return res.status(200).send(scoreAndFeedback); } catch (error) { if (error instanceof TypeError) { @@ -28,7 +44,7 @@ const getResumeScore = async (req: Request, res: Response) => { } }; -const getStreamResumeScore = async (req: Request, res: Response) => { +const getStreamResumeScore = async (req: CustomRequest, res: Response) => { try { const { filename } = req.params; const resumePath = path.resolve(config.resources.resumesDirectoryPath(), filename); @@ -38,6 +54,18 @@ const getStreamResumeScore = async (req: Request, res: Response) => { return res.status(404).send('Resume not found'); } + const cacheKey = getCacheKey(filename, jobDescription); + const cached = resumeScoreCache[cacheKey]; + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + // Stream cached result as SSE + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.write(`data: ${JSON.stringify({ ...cached.data, done: true })}\n\n`); + res.end(); + return; + } + // Set headers for SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); @@ -49,10 +77,12 @@ const getStreamResumeScore = async (req: Request, res: Response) => { }); // Stream the response - const score = await streamScoreResume( + let fullChunk = ''; + const [score, fullText] = await streamScoreResume( resumePath, jobDescription, (chunk) => { + fullChunk += chunk; res.write(`data: ${JSON.stringify({ chunk })}\n\n`); } ); @@ -60,6 +90,11 @@ const getStreamResumeScore = async (req: Request, res: Response) => { // Send the final score res.write(`data: ${JSON.stringify({ score, done: true })}\n\n`); res.end(); + + // Optionally cache the result (score and fullText) + resumeScoreCache[cacheKey] = { data: { score, fullText }, timestamp: Date.now() }; + await updateResume(req.user.id, jobDescription, fullText, score); + } catch (error) { if (error instanceof TypeError) { return res.status(400).send(error.message); @@ -69,12 +104,20 @@ const getStreamResumeScore = async (req: Request, res: Response) => { } }; -const parseResume = async (req: Request, res: Response) => { + +const parseResume = async (req: CustomRequest, res: Response) => { try { - if (!req.file) { + if (!req.body.resumefileName) { return res.status(400).json({ error: 'No resume file uploaded' }); } - const parsed = await parseResumeFields(req.file.buffer, req.file.originalname); + else if (!resumeExists(req.body.resumefileName)) { + return res.status(400).json({ error: 'No resume file uploaded' }); + } + + const resumeFilename = req.body.resumefileName; + const parsed = await parseResumeFields(getResumeBuffer(req.body.resumefileName), resumeFilename); + const resumeData = await saveParsedResume(parsed, req.user.id, resumeFilename, req.body.originfilename); + return res.status(200).json(parsed); } catch (err: any) { console.error('Error parsing resume:', err); @@ -82,4 +125,36 @@ const parseResume = async (req: Request, res: Response) => { } }; -export default { parseResume, getResumeScore, getStreamResumeScore }; \ No newline at end of file +const getResume = async (req: CustomRequest, res: Response) => { + try { + const ownerId = req.user.id; + const resume = await getResumeByOwner(ownerId); + + if (!resume) { + return res.status(404).json({ error: 'Resume not found' }); + } + + return res.status(200).json(resume); + } catch (error) { + console.error('Error retrieving resume:', error); + return handleError(error, res); + } +} + + +const getResumeData = async (req: CustomRequest, res: Response) => { + try { + const ownerId = req.user.id; + // Get the optional version parameter from query string + const version = req.query.version ? parseInt(req.query.version as string) : undefined; + const resume = await getResumeByOwner(ownerId, version); + + return res.status(200).json(resume); + } catch (error) { + console.error('Error retrieving resume data:', error); + return handleError(error, res); + } +}; + +export default { parseResume, getResumeScore, + getStreamResumeScore, getResumeData, getResume }; \ No newline at end of file diff --git a/nextstep-backend/src/controllers/users_controller.ts b/nextstep-backend/src/controllers/users_controller.ts index 7a0a06e..56e0139 100644 --- a/nextstep-backend/src/controllers/users_controller.ts +++ b/nextstep-backend/src/controllers/users_controller.ts @@ -36,6 +36,25 @@ export const getUserById = async (req: Request, res: Response): Promise => } +export const updateUserProfile = async (req: Request, res: Response) => { + const { aboutMe, skills, selectedRole } = req.body; + + if (!req.params.id) { + return res.status(400).json({ error: 'User ID is required' }); + } + + try { + const updatedUser = await usersService.updateUserProfile(req.params.id, aboutMe, skills, selectedRole); + if (!updatedUser) { + return res.status(404).json({ error: 'User not found' }); + } + + res.status(200).json(updatedUser); + } catch (error) { + handleError(error, res); + } +}; + export const updateUserById = async (req: Request, res: Response): Promise => { try { const user = await usersService.updateUserById(req.params.id, req.body); diff --git a/nextstep-backend/src/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts new file mode 100644 index 0000000..75a4250 --- /dev/null +++ b/nextstep-backend/src/models/resume_model.ts @@ -0,0 +1,39 @@ +import mongoose, { Schema } from 'mongoose'; +import {ResumeData} from "types/resume_types"; + +const ResumeSchema = new Schema({ + owner: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + version: { type: Number, required: true }, + rawContentLink: { type: String, required: true }, + parsedData: { + type: { + fileName: { type: String, required: false }, + aboutMe: { type: String, required: false }, + skills: { type: [String], required: false }, + roleMatch: { type: String, required: false }, + experience: { type: [String], required: false }, + jobDescription: { type: String, required: false }, + feedback: { type: String, required: false }, + score: { type: Number, required: false }, + }, + required: false + }, + createdAt: { type: Date, default: Date.now } +}, { versionKey: false }); + + +ResumeSchema.set('toJSON', { + transform: (doc, ret): ResumeData => { + return { + id: ret._id, + owner: ret.owner._id.toString(), + createdAt: ret.createdAt, + updatedAt: ret.updatedAt, + version: ret.version, + rawContentLink: ret.rawContentLink, + parsedData: ret.parsedData + }; + } +}); + +export const ResumeModel = mongoose.model('Resume', ResumeSchema); \ No newline at end of file diff --git a/nextstep-backend/src/models/user_model.ts b/nextstep-backend/src/models/user_model.ts index 46dd15a..b3ee5f8 100644 --- a/nextstep-backend/src/models/user_model.ts +++ b/nextstep-backend/src/models/user_model.ts @@ -23,6 +23,18 @@ const userSchema: Schema = new Schema({ type: Date, default: Date.now }, + aboutMe: { + type: String, + default: "" + }, + skills: { + type: [String], + default: [] + }, + selectedRole: { + type: String, + default: "" + }, authProvider: { type: String } @@ -37,7 +49,10 @@ userSchema.set('toJSON', { password: ret.password as string, imageFilename: ret?.imageFilename as string | undefined, createdAt: ret.createdAt ? ret.createdAt.toISOString() : undefined, - updatedAt: ret.updatedAt ? ret.updatedAt.toISOString() : undefined + updatedAt: ret.updatedAt ? ret.updatedAt.toISOString() : undefined, + aboutMe: ret?.aboutMe as string | undefined, + skills: ret?.skills as string[] | undefined, + selectedRole: ret?.selectedRole as string | undefined }; } }); diff --git a/nextstep-backend/src/routes/resources_routes.ts b/nextstep-backend/src/routes/resources_routes.ts index 016d79b..a69e925 100644 --- a/nextstep-backend/src/routes/resources_routes.ts +++ b/nextstep-backend/src/routes/resources_routes.ts @@ -7,8 +7,10 @@ const router = express.Router(); router.post('/image/user', (req: Request, res: Response) => Resource.createUserImageResource(req as CustomRequest, res)); router.post('/image', Resource.createImageResource); +router.post('/file', Resource.createFileResource); router.get('/image/:filename', Resource.getImageResource); +router.get('/file/:filename', Resource.getFileResource); router.post('/resume', Resource.createResumeResource); diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts index 3e123f4..fb42c81 100644 --- a/nextstep-backend/src/routes/resume_routes.ts +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'; import Resume from '../controllers/resume_controller'; import { CustomRequest } from "types/customRequest"; import multer from 'multer'; +import * as commentsController from "../controllers/comments_controller"; const upload = multer(); @@ -9,8 +10,14 @@ const router = express.Router(); router.get('/score/:filename', Resume.getResumeScore); -router.get('/streamScore/:filename', Resume.getStreamResumeScore); +router.get('/streamScore/:filename', (req: Request, res: Response) => Resume.getStreamResumeScore(req as CustomRequest, res)); + +router.post('/parseResume', upload.single('resume'), (req: Request, res: Response) => Resume.parseResume(req as CustomRequest, res)); + +// TODO - Use it in the frontend after the parse and upload resume +router.get('/resumeData/:version', (req: Request, res: Response) => Resume.getResumeData(req as CustomRequest, res)) + +router.get('/', (req: Request, res: Response) => Resume.getResume(req as CustomRequest, res)) -router.post('/parseResume', upload.single('resume'), Resume.parseResume); export default router; \ No newline at end of file diff --git a/nextstep-backend/src/routes/users_routes.ts b/nextstep-backend/src/routes/users_routes.ts index b781d4c..1f02449 100644 --- a/nextstep-backend/src/routes/users_routes.ts +++ b/nextstep-backend/src/routes/users_routes.ts @@ -13,7 +13,7 @@ router.delete('/:id', validateUserId, handleValidationErrors, (req: Request, res router.patch('/:id', validateUserId, validateUserDataOptional, handleValidationErrors, (req: Request, res: Response) => usersController.updateUserById(req, res)); -// router.post('/', validateUserRegister, handleValidationErrors, (req: Request, res: Response) => usersController.createUser(req, res)); +router.put('/:id', validateUserId, handleValidationErrors, (req: Request, res: Response) => usersController.updateUserProfile(req, res)); export default router; \ No newline at end of file diff --git a/nextstep-backend/src/services/companies_service.ts b/nextstep-backend/src/services/companies_service.ts index fb5e4e3..b7fc991 100644 --- a/nextstep-backend/src/services/companies_service.ts +++ b/nextstep-backend/src/services/companies_service.ts @@ -619,11 +619,18 @@ Content Details: } \`\`\` -Return ONLY the JSON, without any other text, so I could easily retrieve it. +Return ONLY the JSON, without any other text, so I could easily retrieve it. As A correct json format. `; const aiResponse = await chatWithAI(GEN_QUIZ_SYSTEM_PROMPT, [prompt]); - const parsed = JSON.parse(aiResponse.trim().replace("```json", "").replace("```", "")) as any; + + const cleanedResponse = aiResponse + .trim() + .replace(/^```json[\s\n]*/, "") // Remove "json" and any whitespace after it + .replace(/^```/, "") // Remove leading ``` + .replace(/```$/, ""); // Remove trailing ``` + + const parsed = JSON.parse(cleanedResponse) as any; parsed.specialty_tags = parsed.specialty_tags || []; diff --git a/nextstep-backend/src/services/posts_service.ts b/nextstep-backend/src/services/posts_service.ts index b8998ce..f463af5 100644 --- a/nextstep-backend/src/services/posts_service.ts +++ b/nextstep-backend/src/services/posts_service.ts @@ -1,6 +1,6 @@ import {PostModel } from '../models/posts_model'; import { IPost, PostData } from 'types/post_types'; -import {ClientSession, Document} from 'mongoose'; +import {Document} from 'mongoose'; import * as mongoose from 'mongoose'; import * as commentsService from './comments_service'; import * as usersService from './users_service'; @@ -9,7 +9,8 @@ import likeModel from "../models/like_model"; import {CommentData} from "types/comment_types"; import {UserData} from 'types/user_types'; import * as chatService from './chat_api_service'; -import {config} from "../config/config"; +import {config} from "../config/config" + const postToPostData = async (post: Document & IPost): Promise => { diff --git a/nextstep-backend/src/services/resources_service.ts b/nextstep-backend/src/services/resources_service.ts index 8370c26..b4030ac 100644 --- a/nextstep-backend/src/services/resources_service.ts +++ b/nextstep-backend/src/services/resources_service.ts @@ -9,6 +9,47 @@ interface MulterRequest extends Request { file?: Express.Multer.File; } +const createFilesStorage = () => { + // Ensure the directory exists + const filesResourcesDir = config.resources.filesDirectoryPath(); + if (!fs.existsSync(filesResourcesDir)) { + fs.mkdirSync(filesResourcesDir, { recursive: true }); + } + + const filesStorage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, `${filesResourcesDir}/`); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + const id = randomUUID(); + cb(null, id + ext); + } + }); + + return multer({ + storage: filesStorage, + limits: { + fileSize: config.resources.fileMaxSize() + }, + fileFilter: (req, file, cb) => { + const allowedExts = ['.pdf', '.doc', '.docx', '.docs']; + const allowedMimes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ]; + const ext = path.extname(file.originalname).toLowerCase(); + const mime = file.mimetype; + if (allowedExts.includes(ext) && allowedMimes.includes(mime)) { + return cb(null, true); + } else { + return cb(new TypeError(`Invalid file type (${mime}). Allowed: ${allowedExts.join(', ')}`)); + } + } + }); +}; + const createImagesStorage = () => { // Ensure the directory exists const imagesResourcesDir = config.resources.imagesDirectoryPath(); @@ -103,6 +144,26 @@ const uploadImage = (req: MulterRequest): Promise => { }); }; +const uploadFile = (req: MulterRequest): Promise => { + return new Promise((resolve, reject) => { + createFilesStorage().single('file')(req, {} as any, (error) => { + if (error) { + if (error instanceof multer.MulterError || error instanceof TypeError) { + return reject(error); + } else if (!req.file) { + return reject(new TypeError('No file uploaded.')); + } else { + return reject(new Error('Internal Server Error')); + } + } + if (!req.file) { + return reject(new TypeError('No file uploaded.')); + } + resolve(req.file.filename); + }); + }); +}; + const uploadResume = (req: MulterRequest): Promise => { return new Promise((resolve, reject) => { createResumesStorage().single('file')(req, {} as any, (error) => { @@ -123,4 +184,32 @@ const uploadResume = (req: MulterRequest): Promise => { }); }; -export { uploadImage, uploadResume }; \ No newline at end of file +const getResumePath = (filename: string): string => { + const resumesDirectoryPath = config.resources.resumesDirectoryPath(); + const resumePath = path.resolve(resumesDirectoryPath, filename); + + if (!fs.existsSync(resumePath)) { + throw new TypeError('Resume not found'); + } + + return resumePath; +} + +const getResumeBuffer = (filename: string): Buffer => { + const resumePath = getResumePath(filename); + + try { + return fs.readFileSync(resumePath); + } catch (error: any) { + throw new Error(`Failed to read resume file: ${error.message}`); + } +} + +const resumeExists = (filename: string): boolean => { + const resumesDirectoryPath = config.resources.resumesDirectoryPath(); + const resumePath = path.resolve(resumesDirectoryPath, filename); + return fs.existsSync(resumePath); +}; + + +export { uploadImage, uploadResume, getResumeBuffer, resumeExists, uploadFile }; \ No newline at end of file diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index ccbfdc1..8f618f1 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -1,104 +1,28 @@ import { config } from '../config/config'; import path from 'path'; import fs from 'fs'; -import axios from 'axios'; import { chatWithAI, streamChatWithAI } from './chat_api_service'; import mammoth from 'mammoth'; import pdfParse from 'pdf-parse'; import AdmZip from 'adm-zip'; import { DOMParser, XMLSerializer } from 'xmldom'; +import {ParsedResume, ResumeData} from 'types/resume_types'; +import {createResumeExtractionPrompt, createResumeModificationPrompt, feedbackTemplate, SYSTEM_TEMPLATE} from "../utils/resume_handlers/resume_AI_handler"; +import { parseDocument } from '../utils/resume_handlers/resume_files_handler'; +import {ResumeModel} from "../models/resume_model"; +import {Document} from 'mongoose'; -export interface ParsedResume { - aboutMe: string; - skills: string[]; - roleMatch: string; - experience:string[]; - education?: string[]; -} -const SYSTEM_TEMPLATE = `You are a very experienced ATS (Application Tracking System) bot with a deep understanding named Bob the Resume builder. -You will review resumes with or without job descriptions. -You are an expert in resume evaluation and provide constructive feedback with dynamic evaluation. -You should also provide an improvement table, taking into account: -- Content (Medium priority) -- Keyword matching (High priority) -- Hard skills (High priority) -- Soft skills (High priority) -- Overall presentation (Low priority)`; - -const feedbackTemplate = (resumeText: string, jdText: string) => ` -Resume Feedback Report -Here is the resume you provided: -${resumeText} -And the job description: -${jdText} - -Create the Improvement Table in relevance to the resume and give the consideration and suggestion for each section strictly following -the pattern as below and don't just out this guided pattern : -| Area | Consideration | Status | Suggestions | -| ------------- | --------------------------------------------------------------- | ------ | ----------- | -| Content | Measurable Results: At least 5 specific achievements or impact. | Low | | -| | Words to avoid: Negative phrases or clichés. | | | -| Keywords | Hard Skills: Presence and frequency of hard skills. | High | | -| | Soft Skills: Presence and frequency of soft skills. | | | -| Presentation | Education Match: Does the resume list a degree that matches the job requirements? | High | | - -Strengths: -List the strengths of the resume here. - -Detailed Feedback: -Provide detailed feedback on the resume's content, structure, grammar, and relevance to the job description. - -Suggestions: -Provide actionable suggestions for improvement, including specific keywords to include and skills to highlight. - -Based on your analysis, provide a numerical score between 0-100 that represents the overall quality and match of the resume. -The score should be provided at the end of your response in the format: "SCORE: X" where X is the numerical score. -`; -const FEEDBACK_ERROR_MESSAGE = 'The Chat AI feature is turned off. Could not score your resume.'; -const parseDocument = async (filePath: string): Promise => { - const ext = path.extname(filePath).toLowerCase(); - - try { - switch (ext) { - case '.pdf': - return await parsePdf(filePath); - case '.docx': - case '.doc': - return await parseWord(filePath); - case '.txt': - case '.text': - return fs.readFileSync(filePath, 'utf-8'); - default: - throw new Error(`Unsupported file format: ${ext}`); - } - } catch (error: any) { - console.error(`Error parsing document ${filePath}:`, error); - throw new Error(`Failed to parse document: ${error.message}`); - } -}; -const parsePdf = async (filePath: string): Promise => { - try { - const dataBuffer = fs.readFileSync(filePath); - const data = await pdfParse(dataBuffer); - return data.text; - } catch (error: any) { - console.error('Error parsing PDF:', error); - throw new Error('Failed to parse PDF document'); - } -}; +const FEEDBACK_ERROR_MESSAGE = 'The Chat AI feature is turned off. Could not score your resume.'; -const parseWord = async (filePath: string): Promise => { - try { - const result = await mammoth.extractRawText({ path: filePath }); - return result.value; - } catch (error: any) { - console.error('Error parsing Word document:', error); - throw new Error('Failed to parse Word document'); - } + +const resumeToResumeData = async (resume: Document & any): Promise => { + // The mongoose schema's toJSON transform already handles basic conversion + // You could add additional fields here if needed in the future + return resume.toJSON(); }; const scoreResume = async (resumePath: string, jobDescription?: string): Promise<{ score: number; feedback: string }> => { @@ -134,7 +58,7 @@ const streamScoreResume = async ( resumePath: string, jobDescription: string | undefined, onChunk: (chunk: string) => void -): Promise => { +): Promise<[number, string]> => { try { const resumeText = await parseDocument(resumePath); if (resumeText.trim() == '') { @@ -164,7 +88,7 @@ const streamScoreResume = async ( onChunk(FEEDBACK_ERROR_MESSAGE); } - return finalScore; + return [finalScore, fullResponse]; } catch (error: any) { if (error instanceof TypeError) { console.error('TypeError while streaming resume score:', error); @@ -197,23 +121,11 @@ const parseResumeFields = async ( } // 2) Build the extraction prompt - const prompt = ` - Extract from this resume the following fields as JSON: - • "aboutMe": a 1–2 sentence self-summary. - • "skills": an array of technical skills. - • "roleMatch": one-sentence best-fit role suggestion. - • "experience": an array of 3–5 bullet points of key achievements. - - Resume text: - --- - ${text} - --- - Respond with a single JSON object and nothing else. The json object should begin directly with parentheses and have the following structure: {"a": "value", "b": "value", ...} - `; + const prompt = createResumeExtractionPrompt(text); - // 3) Call your Chat AI + // 3) Call Chat AI const aiResponse = await chatWithAI( - SYSTEM_TEMPLATE, // you can reuse your existing SYSTEM_TEMPLATE or define a new one + SYSTEM_TEMPLATE, [prompt] ); @@ -222,4 +134,96 @@ const parseResumeFields = async ( return parsed; }; -export { scoreResume, streamScoreResume, parseResumeFields }; \ No newline at end of file + +const getLatestResumeByUser = async (ownerId: string): Promise => { + try { + const latestResume = await ResumeModel.findOne({ owner: ownerId }) + .sort({ version: -1 }) + .exec(); + + return latestResume ? latestResume.version : 0; // Return version number or 0 if no resume exists + } catch (error) { + console.error('Error finding latest resume:', error); + throw new Error('Failed to retrieve latest resume'); + } +}; + + +const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resumeRawLink: string, filename: string): Promise => { + const lastVersion = await getLatestResumeByUser(ownerId); + const newVersion = lastVersion + 1; + + const newResume = new ResumeModel({ + owner: ownerId, + version: newVersion, + rawContentLink: resumeRawLink, + parsedData: { + fileName: filename, + aboutMe: parsedData.aboutMe, + skills: parsedData.skills, + roleMatch: parsedData.roleMatch, + experience: parsedData.experience + } + }); + + const savedResume = await newResume.save(); + return resumeToResumeData(savedResume); +}; + +const updateResume = async (ownerId: string, jobDescription: string, feedback?: string, score?: number, filename?: string): Promise => { + try { + const resume = await getResumeByOwner(ownerId); + if (!resume) { + throw new Error(`Resume not found`); + } + const parsedData = resume.parsedData as ParsedResume; // Ensure parsedData is of type ParsedResume + if (jobDescription !== parsedData.jobDescription) { + resume.parsedData = { + ...parsedData, + jobDescription: jobDescription || parsedData.jobDescription, + feedback: feedback || parsedData.feedback || '', + score: score || parsedData.score || 0, + fileName: parsedData.fileName || filename || '' + }; + resume.markModified('parsedData'); + await resume.save(); + + } + } + catch (error) { + console.error('Error updating resume:', error); + throw new Error(`Failed to update resume`); + } +} + +const getResumeByOwner = async (ownerId: string, version?: number) => { + try { + let query: {owner: string, version?: number} = { + owner: ownerId, + }; + + // If version is specified, add it to the query + if (version !== undefined) { + query = { ...query, version }; + } + + const resume = await ResumeModel.findOne(query) + .sort(version === undefined ? { version: -1 } : {}) // Sort by version descending only if no specific version requested + .exec(); + + if (!resume) { + if (version !== undefined) { + throw new Error(`Resume version ${version} not found for user ${ownerId}`); + } + console.log(`No resume found for user ${ownerId}`); + } + + return resume; + } catch (error) { + console.error('Error retrieving resume:', error); + throw error; + } +}; + +export { scoreResume, streamScoreResume, parseResumeFields, + saveParsedResume, getResumeByOwner, updateResume, }; \ No newline at end of file diff --git a/nextstep-backend/src/services/users_service.ts b/nextstep-backend/src/services/users_service.ts index 2c74f60..c6e6ac2 100644 --- a/nextstep-backend/src/services/users_service.ts +++ b/nextstep-backend/src/services/users_service.ts @@ -38,6 +38,14 @@ export const getUserById = async (id: string): Promise => { }; +export const updateUserProfile = async (userId: string, aboutMe: string, skills: string[], selectedRole: string) => { + const updatedUser = await UserModel.findByIdAndUpdate( + userId, + { aboutMe, skills, selectedRole }, + { new: true, runValidators: true } + ); + return updatedUser ? userToUserData(updatedUser) : null; +}; export const updateUserById = async (id: string, updateData: Partial): Promise => { if (updateData.password) { diff --git a/nextstep-backend/src/types/resume_types.ts b/nextstep-backend/src/types/resume_types.ts new file mode 100644 index 0000000..4dd5248 --- /dev/null +++ b/nextstep-backend/src/types/resume_types.ts @@ -0,0 +1,35 @@ +import { Document } from 'mongoose'; + + +export interface ParsedResume { + aboutMe: string; + skills: string[]; + roleMatch: string; + experience: string[]; + education?: string[]; + jobDescription?: string; + feedback?: string; + score?: number; + fileName?: string; +} + +export interface ResumeDocument extends Document { + _id: string; + owner: string; + version: number; + rawContentLink: string; + parsedData: ParsedResume; + createdAt?: Date; + updatedAt?: Date; +} + +export interface ResumeData { + id: string; + owner: string; + version: number; + rawContentLink: string; + parsedData: ParsedResume; + createdAt?: Date; + updatedAt?: Date; +} + diff --git a/nextstep-backend/src/types/user_types.ts b/nextstep-backend/src/types/user_types.ts index a0afe89..5f70cd9 100644 --- a/nextstep-backend/src/types/user_types.ts +++ b/nextstep-backend/src/types/user_types.ts @@ -17,6 +17,9 @@ export interface UserData { imageFilename?: string; createdAt?: string, updatedAt?: string, + aboutMe?: string; + skills?: string[]; + selectedRole?: string; } diff --git a/nextstep-backend/src/utils/resume_handlers/resume_AI_handler.ts b/nextstep-backend/src/utils/resume_handlers/resume_AI_handler.ts new file mode 100644 index 0000000..619a275 --- /dev/null +++ b/nextstep-backend/src/utils/resume_handlers/resume_AI_handler.ts @@ -0,0 +1,84 @@ +export const SYSTEM_TEMPLATE = `You are a very experienced ATS (Application Tracking System) bot with a deep understanding named Bob the Resume builder. +You will review resumes with or without job descriptions. +You are an expert in resume evaluation and provide constructive feedback with dynamic evaluation. +You should also provide an improvement table, taking into account: +- Content (Medium priority) +- Keyword matching (High priority) +- Hard skills (High priority) +- Soft skills (High priority) +- Overall presentation (Low priority)`; + +export const feedbackTemplate = (resumeText: string, jdText: string) => ` +Resume Feedback Report +Here is the resume you provided: +${resumeText} +And the job description: +${jdText} + +Create the Improvement Table in relevance to the resume and give the consideration and suggestion for each section strictly following +the pattern as below and don't just out this guided pattern : +| Area | Consideration | Status | Suggestions | +| ------------- | --------------------------------------------------------------- | ------ | ----------- | +| Content | Measurable Results: At least 5 specific achievements or impact. | Low | | +| | Words to avoid: Negative phrases or clichés. | | | +| Keywords | Hard Skills: Presence and frequency of hard skills. | High | | +| | Soft Skills: Presence and frequency of soft skills. | | | +| Presentation | Education Match: Does the resume list a degree that matches the job requirements? | High | | + +Strengths: +List the strengths of the resume here. + +Detailed Feedback: +Provide detailed feedback on the resume's content, structure, grammar, and relevance to the job description. + +Suggestions: +Provide actionable suggestions for improvement, including specific keywords to include and skills to highlight. + +Based on your analysis, provide a numerical score between 0-100 that represents the overall quality and match of the resume. +The score should be provided at the end of your response in the format: "SCORE: X" where X is the numerical score. +`; + +export const createResumeModificationPrompt = (resumeContent: string, feedback: string, jobDescription: string): string => { + return `You are a resume expert. Please modify the following resume content based on the feedback and job description. + +Current Resume Content: +${resumeContent} + +Feedback: +${feedback} + +Job Description: +${jobDescription} + +IMPORTANT: You must return your response in the following EXACT JSON format. Do not include any other text or explanation: + +[ + { + "paragraphIndex": 0, + "text": "First paragraph content here" + } +] + +Rules: +1. Return ONLY the JSON array, nothing else +2. Each paragraph must maintain its original structure +3. The text content should be updated based on the feedback while preserving formatting +4. Maintain the same number of paragraphs as the original +5. Do not include any markdown, formatting, or additional text`; +}; + +export const createResumeExtractionPrompt = (resumeText: string): string => { + return ` + Extract from this resume the following fields as JSON: + • "aboutMe": a 1–2 sentence self-summary. + • "skills": an array of technical skills. + • "roleMatch": one-sentence best-fit role suggestion. + • "experience": an array of 3–5 bullet points of key achievements. + + Resume text: + --- + ${resumeText} + --- + Respond with a single JSON object and nothing else. The json object should begin directly with parentheses and have the following structure: {"a": "value", "b": "value", ...} + `; +}; \ No newline at end of file diff --git a/nextstep-backend/src/utils/resume_handlers/resume_files_handler.ts b/nextstep-backend/src/utils/resume_handlers/resume_files_handler.ts new file mode 100644 index 0000000..7e6f463 --- /dev/null +++ b/nextstep-backend/src/utils/resume_handlers/resume_files_handler.ts @@ -0,0 +1,49 @@ +import path from "path"; +import fs from "fs"; +import pdfParse from "pdf-parse"; +import mammoth from "mammoth"; + +export const parseDocument = async (filePath: string): Promise => { + const ext = path.extname(filePath).toLowerCase(); + + try { + switch (ext) { + case '.pdf': + return await parsePdf(filePath); + case '.docx': + case '.doc': + return await parseWord(filePath); + case '.txt': + case '.text': + return fs.readFileSync(filePath, 'utf-8'); + default: + throw new Error(`Unsupported file format: ${ext}`); + } + } catch (error: any) { + console.error(`Error parsing document ${filePath}:`, error); + throw new Error(`Failed to parse document: ${error.message}`); + } +}; + +const parsePdf = async (filePath: string): Promise => { + try { + const dataBuffer = fs.readFileSync(filePath); + const data = await pdfParse(dataBuffer); + return data.text; + } catch (error: any) { + console.error('Error parsing PDF:', error); + throw new Error('Failed to parse PDF document'); + } +}; + +const parseWord = async (filePath: string): Promise => { + try { + const result = await mammoth.extractRawText({ path: filePath }); + return result.value; + } catch (error: any) { + console.error('Error parsing Word document:', error); + throw new Error('Failed to parse Word document'); + } +}; + + diff --git a/nextstep-frontend/src/components/Footer.tsx b/nextstep-frontend/src/components/Footer.tsx index 5fb4ac1..63dfb7f 100644 --- a/nextstep-frontend/src/components/Footer.tsx +++ b/nextstep-frontend/src/components/Footer.tsx @@ -1,124 +1,278 @@ -import React from 'react'; -import { Box, Container, Typography, Link, useTheme, useMediaQuery } from '@mui/material'; -import { Link as RouterLink } from 'react-router-dom'; +"use client" + +import type React from "react" +import { Box, Container, Typography, Link, useTheme, useMediaQuery, Stack, IconButton } from "@mui/material" +import { Link as RouterLink } from "react-router-dom" +import { GitHub, LinkedIn, Twitter } from "@mui/icons-material" +import { motion } from "framer-motion" const Footer: React.FC = () => { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) return ( - - - NextStep - - - Empowering your career journey - - - - - - - Quick Links + + + + NextStep + + + Empowering your career journey - - Feed - - - Profile - - - Chat - + + + + + + + + + + + + + - - - Resources - - - Resume Builder - - - Career Quiz - - - Dashboard - + + + + + Quick Links + + + Feed + + + Profile + + + Chat + + + + + + Resources + + + Resume Builder + + + Career Quiz + + + Dashboard + + - + - + © {new Date().getFullYear()} NextStep. All rights reserved. - + Terms - + Privacy - ); -}; + ) +} -export default Footer; \ No newline at end of file +export default Footer diff --git a/nextstep-frontend/src/components/Layout.tsx b/nextstep-frontend/src/components/Layout.tsx index a3948b3..795e2ae 100644 --- a/nextstep-frontend/src/components/Layout.tsx +++ b/nextstep-frontend/src/components/Layout.tsx @@ -1,81 +1,133 @@ -import { Container, Box, useTheme } from '@mui/material'; -import React from 'react'; -import './Layout.css'; +"use client" + +import { Container, Box, useTheme } from "@mui/material" +import type React from "react" +import { motion } from "framer-motion" +import "./Layout.css" interface LayoutProps { - className?: string; - children: React.ReactNode; + className?: string + children: React.ReactNode } -const Layout: React.FC = ({ - className = '', - children -}) => { - const theme = useTheme(); +const Layout: React.FC = ({ className = "", children }) => { + const theme = useTheme() return ( - - {children} - + + {children} + + - ); -}; + ) +} -export default Layout; \ No newline at end of file +export default Layout diff --git a/nextstep-frontend/src/components/LeftBar.tsx b/nextstep-frontend/src/components/LeftBar.tsx index e085c09..917ce97 100644 --- a/nextstep-frontend/src/components/LeftBar.tsx +++ b/nextstep-frontend/src/components/LeftBar.tsx @@ -1,240 +1,468 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { Box, Drawer, List, ListItem, ListItemIcon, ListItemText, Tooltip, Divider, ListItemButton } from '@mui/material'; -import { Home, Person, Message, Logout, DocumentScannerTwoTone, Feed, Quiz, LightMode, DarkMode } from '@mui/icons-material'; -import { getUserAuth, removeUserAuth } from "../handlers/userAuth.ts"; -import api from "../serverApi.ts"; -import fullLogo from '../../assets/NextStep.png'; -import partialLogo from '../../assets/NextStepLogo.png'; -import { useTheme } from '../contexts/ThemeContext'; +import type React from "react" +import { useState, useEffect } from "react" +import { useNavigate, useLocation } from "react-router-dom" +import { + Box, + Drawer, + List, + ListItem, + ListItemIcon, + ListItemText, + Tooltip, + Divider, + ListItemButton, + IconButton, + useTheme, + alpha, +} from "@mui/material" +import { + Dashboard as DashboardIcon, + Person, + Message, + Logout, + DocumentScannerTwoTone, + Feed, + Quiz, + LightMode, + DarkMode, + Menu as MenuIcon, +} from "@mui/icons-material" +import { getUserAuth, removeUserAuth } from "../handlers/userAuth.ts" +import api from "../serverApi.ts" +import fullLogo from "../../assets/NextStep.png" +import partialLogo from "../../assets/NextStepLogo.png" +import { useTheme as useCustomTheme } from "../contexts/ThemeContext" +import { motion } from "framer-motion" const LeftBar: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const [collapsed, setCollapsed] = useState(true); - const { isDarkMode, toggleTheme } = useTheme(); + const { isDarkMode, toggleTheme } = useCustomTheme(); + const theme = useTheme(); + const [mobileOpen, setMobileOpen] = useState(false); useEffect(() => { const handleResize = () => { if (window.innerWidth < 600) { setCollapsed(true); } - }; + } - handleResize(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); + handleResize() + window.addEventListener("resize", handleResize) + return () => window.removeEventListener("resize", handleResize) + }, []) const handleLogout = async () => { try { - const userAuth = getUserAuth(); + const userAuth = getUserAuth() if (userAuth) { - await api.post('/auth/logout', {}, { - headers: { Authorization: `Bearer ${userAuth.accessToken}` } - }); + await api.post( + "/auth/logout", + {}, + { + headers: { Authorization: `Bearer ${userAuth.accessToken}` }, + }, + ) } } catch (error) { - console.error('Logout error:', error); + console.error("Logout error:", error) } finally { - removeUserAuth(); - navigate('/login'); + removeUserAuth() + navigate("/login") } - }; + } const menuItems = [ - { text: 'Dashboard', icon: , path: '/main-dashboard' }, - { text: 'Resume', icon: , path: '/resume' }, - { text: 'Quiz', icon: , path: '/quiz' }, - { text: 'Feed', icon: , path: '/feed' }, - { text: 'Chat', icon: , path: '/chat' }, - { text: 'Profile', icon: , path: '/profile' }, - ]; + { text: "Dashboard", icon: , path: "/main-dashboard" }, + { text: "Resume", icon: , path: "/resume" }, + { text: "Quiz", icon: , path: "/quiz" }, + { text: "Feed", icon: , path: "/feed" }, + { text: "Chat", icon: , path: "/chat" }, + { text: "Profile", icon: , path: "/profile" }, + ] - return ( - setCollapsed(false)} - onMouseLeave={() => setCollapsed(true)} - variant="permanent" + const handleDrawerToggle = () => { + setMobileOpen(!mobileOpen) + } + + const drawer = ( + - - navigate('/main-dashboard')} - /> + + navigate("/main-dashboard")} + /> + - - - {menuItems.map((item) => ( - - navigate(item.path)} - sx={{ - minHeight: 48, - justifyContent: collapsed ? 'center' : 'initial', - px: 2.5, - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', - '&.Mui-selected': { - bgcolor: 'primary.main', - color: 'primary.contrastText', - '&:hover': { - bgcolor: 'primary.dark', - }, - '& .MuiListItemIcon-root': { - color: 'inherit', - }, - }, - '&:hover': { - bgcolor: 'action.hover', - transform: 'translateX(4px)', - }, - }} - > - + + {/* Main Menu Items - Takes up available space */} + + + {menuItems.map((item, index) => { + const isActive = location.pathname === item.path + return ( + - {item.icon} - - - - - ))} - - - - - - - - + + { + navigate(item.path) + if (mobileOpen) setMobileOpen(false) + }} + sx={{ + minHeight: 44, // Reduced height + justifyContent: collapsed ? "center" : "initial", + px: 2, + py: 1, + borderRadius: "12px", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + position: "relative", + overflow: "hidden", + "&.Mui-selected": { + bgcolor: isActive ? alpha(theme.palette.primary.main, 0.15) : "transparent", + color: isActive ? theme.palette.primary.main : theme.palette.text.primary, + "&:hover": { + bgcolor: alpha(theme.palette.primary.main, 0.25), + }, + "&::before": { + content: '""', + position: "absolute", + left: 0, + top: "50%", + transform: "translateY(-50%)", + width: 4, + height: "60%", + backgroundColor: theme.palette.primary.main, + borderRadius: "0 4px 4px 0", + }, + "& .MuiListItemIcon-root": { + color: theme.palette.primary.main, + }, + }, + "&:hover": { + bgcolor: alpha(theme.palette.action.hover, 0.8), + transform: "translateX(4px)", + }, + }} + > + + {item.icon} + + + {isActive && !collapsed && ( + + )} + + + + + ) + })} + + + + + + {/* Bottom Actions - Fixed at bottom */} + + + + + - {isDarkMode ? : } - - - - - - - - - + {isDarkMode ? : } + + + + + + + + - - - - - - - - - ); -}; + + + + + + + + + + + ) + + return ( + <> + {/* Mobile menu toggle button */} + + + + + + + {/* Mobile drawer */} + + {drawer} + + + {/* Desktop drawer */} + setCollapsed(false)} + onMouseLeave={() => setCollapsed(true)} + variant="permanent" + sx={{ + display: { xs: "none", md: "block" }, + width: collapsed ? 72 : 240, + flexShrink: 0, + position: "fixed", + "& .MuiDrawer-paper": { + width: collapsed ? 72 : 240, + boxSizing: "border-box", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + bgcolor: "background.paper", + borderRight: "1px solid", + borderColor: alpha(theme.palette.divider, 0.5), + overflowX: "hidden", + overflowY: "hidden", // Prevent vertical scrolling + backgroundImage: isDarkMode + ? "linear-gradient(to bottom, rgba(31, 41, 55, 0.95), rgba(17, 24, 39, 0.95))" + : "linear-gradient(to bottom, rgba(255, 255, 255, 0.95), rgba(249, 250, 251, 0.95))", + backdropFilter: "blur(10px)", + boxShadow: "4px 0 20px rgba(0, 0, 0, 0.15)", + "&:hover": { + width: 240, + "& .logo-text": { + opacity: 1, + transform: "translateX(0)", + }, + "& .menu-text": { + opacity: 1, + transform: "translateX(0)", + }, + }, + }, + }} + > + {drawer} + + + ) +} -export default LeftBar; \ No newline at end of file +export default LeftBar; diff --git a/nextstep-frontend/src/components/LinkedinJobs.tsx b/nextstep-frontend/src/components/LinkedinJobs.tsx index 8592af7..2a16744 100644 --- a/nextstep-frontend/src/components/LinkedinJobs.tsx +++ b/nextstep-frontend/src/components/LinkedinJobs.tsx @@ -1,33 +1,68 @@ -import React, { useState } from 'react'; -import { Box, Typography, Button, Grid, CircularProgress, IconButton, TextField, MenuItem, Select, FormControl, InputLabel, Dialog, DialogTitle, DialogContent, DialogActions, Chip, Stack } from '@mui/material'; -import { ExpandLess, LinkedIn, Settings } from '@mui/icons-material'; +import type React from "react" +import { useState } from "react" +import { + Box, + Typography, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Card, + CardContent, + IconButton, + Dialog, + DialogContent, + DialogTitle, + useTheme, + Grid, + Chip, + DialogActions, + Stack, + Avatar, + alpha, + Divider, +} from "@mui/material" +import { + ExpandMore, + ExpandLess, + LinkedIn, + Fullscreen, + Close, + Work, + LocationOn, + Business, + Add as AddIcon, +} from "@mui/icons-material" interface Job { - position: string; - company: string; - location: string; - jobUrl?: string; - companyLogo?: string; - date?: string; - salary?: string; + position: string + company: string + location: string + jobUrl?: string + companyLogo?: string + date?: string + salary?: string } interface LinkedinJobsProps { - jobs: Job[]; - loadingJobs: boolean; - fetchJobs: (settings: LinkedInSettings) => Promise; - showJobRecommendations: boolean; - toggleJobRecommendations: () => void; - skills: string[]; - selectedRole: string; + jobs: Job[] + loadingJobs: boolean + fetchJobs: (settings: LinkedInSettings) => Promise + showJobRecommendations: boolean + toggleJobRecommendations: () => void + skills: string[] + selectedRole: string } interface LinkedInSettings { - location: string; - dateSincePosted: string; - jobType: string; - experienceLevel: string; - skills: string[]; + location: string + dateSincePosted: string + jobType: string + experienceLevel: string + skills: string[] } const LinkedinJobs: React.FC = ({ @@ -40,277 +75,827 @@ const LinkedinJobs: React.FC = ({ selectedRole, }) => { const [settings, setSettings] = useState({ - location: 'Israel', - dateSincePosted: 'past month', - jobType: 'full time', - experienceLevel: 'all', + location: "Israel", + dateSincePosted: "past month", + jobType: "full time", + experienceLevel: "all", skills: skills.slice(0, 3), // Limit to first 3 skills - }); + }) + const theme = useTheme() - const [selectedJob, setSelectedJob] = useState(null); - const [jobDetails, setJobDetails] = useState(null); + const [selectedJob, setSelectedJob] = useState(null) + const [jobDetails, setJobDetails] = useState(null) + const [fullScreen, setFullScreen] = useState(false) + const [fullScreenSettings, setFullScreenSettings] = useState({ ...settings }) + const [newSkill, setNewSkill] = useState("") const handleSettingChange = (key: keyof LinkedInSettings, value: string) => { - setSettings((prev) => ({ ...prev, [key]: value })); - }; + setSettings((prev) => ({ ...prev, [key]: value })) + } + + const handleFullScreenSettingChange = (key: keyof LinkedInSettings, value: string) => { + setFullScreenSettings((prev) => ({ ...prev, [key]: value })) + } const handleFetchJobs = () => { - fetchJobs(settings); - }; + fetchJobs(settings) + } + + const handleFullScreenFetchJobs = () => { + fetchJobs(fullScreenSettings) + } const handleViewJob = (job: Job) => { - setSelectedJob(job); - setJobDetails(job); - }; + setSelectedJob(job) + setJobDetails(job) + } const handleCloseDialog = () => { - setSelectedJob(null); - setJobDetails(null); - }; + setSelectedJob(null) + setJobDetails(null) + } const handleGenerateQuiz = (job: Job) => { - const subject = `${job.position} at ${job.company}`; - const quizUrl = `/quiz?subject=${encodeURIComponent(subject)}`; - window.open(quizUrl, '_blank'); - }; + const subject = `${job.position} at ${job.company}` + const quizUrl = `/quiz?subject=${encodeURIComponent(subject)}` + window.open(quizUrl, "_blank") + } + + const handleFullScreenOpen = () => { + setFullScreenSettings({ ...settings }) + setFullScreen(true) + } + + const handleFullScreenClose = () => { + setFullScreen(false) + } + + const handleAddSkillFullScreen = (skill: string) => { + const trimmed = skill.trim() + if (!trimmed || fullScreenSettings.skills.includes(trimmed) || fullScreenSettings.skills.length >= 3) return + setFullScreenSettings((prev) => ({ + ...prev, + skills: [...prev.skills, trimmed], + })) + setNewSkill("") + } + + const handleDeleteSkillFullScreen = (skillToDelete: string) => { + setFullScreenSettings((prev) => ({ + ...prev, + skills: prev.skills.filter((s) => s !== skillToDelete), + })) + } return ( - - - - - - Job Recommendations - - - - {showJobRecommendations ? : } - - - {showJobRecommendations && ( - <> - - - - - Selected Role:{' '} - {selectedRole ? selectedRole : Choose a role} - - - - handleSettingChange('location', e.target.value)} - fullWidth - /> - - - - Date Since Posted - - - - - - Job Type - - - - - - Experience Level - - - - + <> + + + + + + + + + + Job Recommendations + + + Find your next opportunity + + + + + + + + + - - - Skills Filter: - - - {settings.skills.map((skill, index) => ( - { - const updatedSkills = [...settings.skills]; - updatedSkills.splice(index, 1); - setSettings((prev) => ({ ...prev, skills: updatedSkills })); - }} - sx={{ backgroundColor: '#e3f2fd', color: '#0d47a1' }} - /> - ))} - - {settings.skills.length < 3 && { - const input = e.target as HTMLInputElement; - if (e.key === 'Enter' && input.value.trim()) { - const newSkill = input.value.trim(); - if (!settings.skills.includes(newSkill) && settings.skills.length < 3) { - setSettings((prev) => ({ - ...prev, - skills: [...prev.skills, newSkill], - })); - } - input.value = ''; - } - }} - error={settings.skills.length == 0} - placeholder="Type a skill and press Enter" - sx={{ mt: 1 }} - /> - } - -
- + - {jobs.length > 0 ? ( - - {jobs.map((job, index) => ( - - + + + + + Selected Role:{" "} + {selectedRole ? ( + + ) : ( + + )} + + + + handleSettingChange("location", e.target.value)} + fullWidth + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: alpha(theme.palette.background.default, 0.5), + }, + }} + /> + + + + Date Since Posted + + + + + + Job Type + + + + + + Experience Level + + + + + + + + + Skills Filter: + + + {settings.skills.map((skill, index) => ( + { + const updatedSkills = [...settings.skills] + updatedSkills.splice(index, 1) + setSettings((prev) => ({ ...prev, skills: updatedSkills })) + }} + sx={{ + backgroundColor: alpha("#0077b5", 0.1), + color: "#0077b5", + "&:hover": { + backgroundColor: alpha("#0077b5", 0.2), + }, + }} + /> + ))} + + {settings.skills.length < 3 && ( + { + const input = e.target as HTMLInputElement + if (e.key === "Enter" && input.value.trim()) { + const newSkill = input.value.trim() + if (!settings.skills.includes(newSkill) && settings.skills.length < 3) { + setSettings((prev) => ({ + ...prev, + skills: [...prev.skills, newSkill], + })) + } + input.value = "" + } + }} + error={settings.skills.length === 0} + placeholder="Type a skill and press Enter" sx={{ - p: 2, - border: '1px solid #ddd', - borderRadius: 2, - height: '220px', - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - overflowY: 'auto', + mt: 1, + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: alpha(theme.palette.background.default, 0.5), + }, }} - > - - - {job.companyLogo && ( + /> + )} + + + + + + + {jobs.length > 0 ? ( + + {jobs.map((job, index) => ( + + + + + {job.companyLogo && ( + {`${job.company} + )} + + {job.company} + + + + {job.position} + + + + {job.location} + + + + + + + + ))} + + ) : ( + + + + {!selectedRole || skills.length === 0 + ? "Select a role and add skills to start searching" + : "No job recommendations found. Try adjusting your search settings."} + + + )} + + {/* Job Details Dialog */} + + + + {selectedJob?.position} + + + + {jobDetails ? ( + <> + + {jobDetails.companyLogo && ( {`${job.company} )} - - {job.company} - + + + {jobDetails.company} + + + + {jobDetails.location} + + - {job.position.toLowerCase()} -
- - {job.location} + + ) : ( + Failed to load job details. + )} +
+ + + {selectedJob?.jobUrl && ( + <> + + + + )} + +
+ + )} +
+
+ + {/* Enhanced Dialog */} + + + + + + LinkedIn Job Search + + + + + + + + + + {/* Search Settings */} + + + + + + Search Settings + + + + handleFullScreenSettingChange("location", e.target.value)} + fullWidth + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: alpha(theme.palette.background.default, 0.5), + }, + }} + /> + + + Date Since Posted + + + + + Job Type + + + + + Experience Level + + + + + + + + Skills Filter (Max 3) + + {fullScreenSettings.skills.map((skill, index) => ( + handleDeleteSkillFullScreen(skill)} + sx={{ + backgroundColor: alpha("#0077b5", 0.1), + color: "#0077b5", + "&:hover": { + backgroundColor: alpha("#0077b5", 0.2), + }, + }} + /> + ))} + + {fullScreenSettings.skills.length < 3 && ( + + setNewSkill(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && newSkill.trim()) { + handleAddSkillFullScreen(newSkill) + } + }} + placeholder="Add skill..." + sx={{ + flexGrow: 1, + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: alpha(theme.palette.background.default, 0.5), + }, + }} + /> + handleAddSkillFullScreen(newSkill)} + disabled={!newSkill.trim()} + sx={{ + bgcolor: alpha("#0077b5", 0.1), + color: "#0077b5", + "&:hover": { + bgcolor: alpha("#0077b5", 0.2), + }, + }} + > + + + + )} + -
- - ))} + + + - ) : ( - - {!selectedRole || skills.length == 0 ? "Select a role or add skills to the search." : "No job recommendations found. Try adjusting your search settings."} - - )} - {/* Job Details Dialog */} - - - {selectedJob?.position} - - - {jobDetails ? ( - <> - - {jobDetails.companyLogo && ( - {`${jobDetails.company} - )} - {jobDetails.company} - - - {jobDetails.location} - - + {/* Job Results */} + + + + Job Results ({jobs.length}) + + + {selectedRole && ( + <> + Showing results for {selectedRole} in{" "} + {fullScreenSettings.location} + + )} + + + + {jobs.length > 0 ? ( + + {jobs.map((job, index) => ( + + + + + {job.companyLogo && ( + {`${job.company} + )} + + + {job.company} + + + {job.position} + + + + + + + {job.location} + + + + + {job.jobUrl && ( + + )} + + + + + ))} + ) : ( - Failed to load job details. - )} - - - - {selectedJob?.jobUrl && ( -
- - -
+ + + + No Jobs Found + + + {!selectedRole || fullScreenSettings.skills.length === 0 + ? "Configure your search settings to find relevant opportunities" + : "Try adjusting your search criteria or expanding your location"} + + )} -
-
- - )} -
- ); -}; - -export default LinkedinJobs; + + + + + + ) +} + +export default LinkedinJobs diff --git a/nextstep-frontend/src/components/NewPost.tsx b/nextstep-frontend/src/components/NewPost.tsx index 15a0dbe..2284080 100644 --- a/nextstep-frontend/src/components/NewPost.tsx +++ b/nextstep-frontend/src/components/NewPost.tsx @@ -1,165 +1,370 @@ -import React, { useState } from 'react'; +"use client" + +import type React from "react" +import { useState, useEffect, useRef } from "react" import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, Button, + Box, Typography, - Container, - Modal, - Snackbar, + CircularProgress, + IconButton, Alert, -} from '@mui/material'; -import FroalaEditor from 'react-froala-wysiwyg'; -import 'froala-editor/css/froala_style.min.css'; -import 'froala-editor/css/froala_editor.pkgd.min.css'; -import 'froala-editor/js/plugins/image.min.js'; -import api from "../serverApi.ts"; -import { getUserAuth } from '../handlers/userAuth.ts'; -import { config } from '../config.ts'; - -type Props = { - open: boolean; - onClose: () => void; - onPostCreated?: () => void; -}; - -const NewPostModal: React.FC = ({ open, onClose, onPostCreated }) => { - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); + useTheme, + alpha, +} from "@mui/material" +import { Close } from "@mui/icons-material" +import api from "../serverApi" +import { getUserAuth } from "../handlers/userAuth" +// Import Froala Editor components and styles +import FroalaEditorComponent from "react-froala-wysiwyg" +import "froala-editor/css/froala_style.min.css" +import "froala-editor/css/froala_editor.pkgd.min.css" +import "froala-editor/js/plugins/image.min.js" +import "froala-editor/js/plugins/link.min.js" +import "froala-editor/js/plugins/lists.min.js" +import "froala-editor/js/plugins/paragraph_format.min.js" +import "froala-editor/js/plugins/table.min.js" +import "froala-editor/js/plugins/file.min.js" +import { config } from "../config" + +interface NewPostModalProps { + open: boolean + onClose: () => void + onPostCreated: () => void + withResume?: boolean +} + +const NewPostModal: React.FC = ({ open, onClose, onPostCreated, withResume = false }) => { + const theme = useTheme() + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + const [resumeFile, setResumeFile] = useState(null) + const editorRef = useRef(null) const auth = getUserAuth(); - const [error, setError] = useState(null); + const [mountedEditor, setMountedEditor] = useState(false) + + useEffect(() => { + if (open) { + const timeout = setTimeout(() => { + setMountedEditor(true) + }, 100) // let the DOM settle + return () => clearTimeout(timeout) + } else { + setMountedEditor(false) + } + }, [open]) + + // Froala editor configuration + const editorConfig = { + placeholderText: "Write your post content here...", + charCounterCount: true, + toolbarButtons: [ + "bold", "italic", "underline", "strikeThrough", "|", + "paragraphFormat", "align", "formatOL", "formatUL", "|", + "insertLink", "insertImage", "insertFile", "insertTable", "|", + "html", + ], + heightMin: 200, + imageAllowedTypes: ['jpeg', 'jpg', 'png', 'gif'], + fileAllowedTypes: ['*'], + imageMaxSize: 5 * 1024 * 1024, + fileMaxSize: 10 * 1024 * 1024, + events: { + 'image.beforeUpload': function (files: File[]) { + const editor = this as any; + const file = files[0]; + + const formData = new FormData(); + formData.append("file", file); + + fetch(`${config.app.backend_url()}/resource/image`, { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + }, + body: formData, + }) + .then(res => res.text()) // assuming backend returns just the image ID + .then(imageId => { + const imageUrl = `${config.app.backend_url()}/resource/image/${imageId}`; + editor.image.insert(imageUrl, null, null, editor.image.get()); + }) + .catch(err => { + console.error("Image upload error:", err); + setError("Failed to upload image."); + }); + + return false; + }, + + 'file.beforeUpload': function (files: File[]) { + const editor = this as any; + const file = files[0]; + + const formData = new FormData(); + formData.append("file", file); + + fetch(`${config.app.backend_url()}/resource/file`, { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + }, + body: formData, + }) + .then(res => res.text()) + .then((filename) => { + const fileUrl = `${config.app.backend_url()}/resource/file/${filename}`; + const html = `${file.name}`; + editor.html.insert(html); + }) + .catch(err => { + console.error("File upload error:", err); + setError("Failed to upload file."); + }); + + return false; + }, + }, + }; + + // Fetch resume data when opening in resume mode + useEffect(() => { + if (open && withResume) { + fetchResumeData() + setTitle("My Professional Resume") + setContent("

I wanted to share my professional resume with the community.

") + setError(null) + } + else if (open) { + setTitle("") + setContent("

I wanted to share....

") + setError(null) + setResumeFile(null) + } + }, [open, withResume]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + // Reset form when modal is opened + // useEffect(() => { + // if (open) { + // setTitle(withResume ? "My Professional Resume" : "") + // setContent(withResume ? "

I wanted to share my professional resume with the community.

" : "") + // setError(null) + // } + // }, [open, withResume]) + // Clean up when component unmounts + useEffect(() => { + return () => { + // Clean up any resources if needed + if (resumeFile) { + URL.revokeObjectURL(URL.createObjectURL(resumeFile)) + } + } + }, [resumeFile]) + + const fetchResumeData = async () => { try { - // Submit the post with the content (images are already uploaded and URLs are in place) - await api.post(`/post`, { - title, - content, + const resumeResponse = await api.get("/resume") + + if (resumeResponse.data && resumeResponse.data.rawContentLink) { + const resumeFileResponse = await api.get(`/resource/resume/${resumeResponse.data.rawContentLink}`, { + responseType: "blob", + }) + + const resumeBlob = new Blob([resumeFileResponse.data], { type: "application/pdf" }) + const resumeFileObj = new File([resumeBlob], `${resumeResponse.data.parsedData.fileName}`, { + type: "application/pdf", + }) + + setResumeFile(resumeFileObj) + } else { + setError("No resume found. Please upload a resume first.") + } + } catch (err) { + console.error("Failed to fetch resume data:", err) + setError("Failed to load resume. Please try again later.") + } + } + + const insertResumeIntoEditor = async () => { + if (!resumeFile || !editorRef.current?.editor) return; + + try { + const formData = new FormData(); + formData.append("file", resumeFile); + + const response = await api.post("/resource/file", formData, { + headers: { + "Content-Type": "multipart/form-data", + Authorization: `Bearer ${auth.accessToken}`, + }, }); + + // const fileUrl = response.data; + const fileName = resumeFile.name; + + const fileUrl = `${config.app.backend_url()}/resource/file/${response.data}`; - onClose(); - onPostCreated?.(); // Refresh feed if needed + const resumeHtml = `

📄 ${fileName}

`; + editorRef.current.editor.html.insert(resumeHtml); } catch (error) { - setError('Error creating post: ' + error); + console.error("Failed to upload resume:", error); + setError("Failed to upload resume file."); } }; + + + const handleSubmit = async () => { + if (!title.trim()) { + setError("Please enter a title for your post") + return + } + + if (!content.trim()) { + setError("Please enter content for your post") + return + } + + try { + setIsSubmitting(true) + setError(null) + + // Create the post with content that includes embedded files + await api.post( + "/post", + { title, content }, + { + headers: { + Authorization: `Bearer ${auth.accessToken}`, + }, + }, + ) + + onPostCreated() + onClose() + } catch (err) { + console.error("Failed to create post:", err) + setError("Failed to create post. Please try again later.") + } finally { + setIsSubmitting(false) + } + } + + const handleContentChange = (content: string) => { + setContent(content) + } + + useEffect(() => { + if (open && withResume && resumeFile) { + insertResumeIntoEditor() + } + }, [open, withResume, resumeFile]) return ( - - + - - Create New Post + + {withResume ? "Share Your Resume" : "Create New Post"} -
- setTitle(e.target.value)} - style={{ - width: '100%', - marginBottom: '1rem', - padding: '10px', - fontSize: '16px', - backgroundColor: 'transparent', - color: 'inherit', - border: '1px solid', - borderColor: 'divider', - borderRadius: '4px', - }} - required - /> - response.text()) - .then(imageId => { - // Construct the full image URL - const imageUrl = `${config.app.backend_url()}/resource/image/${imageId}`; - // Insert the uploaded image - editor.image.insert(imageUrl, null, null, editor.image.get()); - }) - .catch(error => { - setError('Error uploading image: ' + error); - }); - - return false; // Prevent default upload - }, - 'image.error': function (error: any, response: any) { - setError('Image upload error: ' + error + ', response: ' + response); - } - } - }} - /> - - - - setError(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - > - setError(null)}> - {error} - - -
-
- ); -}; - -export default NewPostModal; + + + + + + + {error && ( + + {error} + + )} + + setTitle(e.target.value)} + sx={{ mb: 2 }} + /> + + + {open && mountedEditor && ( + + )} + + + + + + + + + ) +} + +export default NewPostModal diff --git a/nextstep-frontend/src/pages/Feed.tsx b/nextstep-frontend/src/pages/Feed.tsx index 2c0b226..bfe1bca 100644 --- a/nextstep-frontend/src/pages/Feed.tsx +++ b/nextstep-frontend/src/pages/Feed.tsx @@ -1,11 +1,12 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { useNavigate } from "react-router-dom" import { Typography, Box, - CircularProgress, Button, - List, Card, CardContent, CardActions, @@ -20,36 +21,58 @@ import { Switch, FormControlLabel, Container, -} from "@mui/material"; -import { ThumbUp, Message, Delete } from '@mui/icons-material'; -import { Post } from "../models/Post.tsx"; -import api from "../serverApi.ts"; -import {getUserAuth} from "../handlers/userAuth.ts"; -import defaultProfileImage from '../../assets/defaultProfileImage.jpg'; // Import the default profile image -import NewPostModal from '../components/NewPost.tsx'; - + Divider, + Paper, + Chip, + useTheme, + alpha, + Tooltip, + Skeleton, +} from "@mui/material" +import { + ThumbUp, + Message, + Delete, + Bookmark, + BookmarkBorder, + Article, + Send, + Description, +} from "@mui/icons-material" +import type { Post } from "../models/Post.tsx" +import api from "../serverApi.ts" +import { getUserAuth } from "../handlers/userAuth.ts" +import defaultProfileImage from "../../assets/defaultProfileImage.jpg" +import NewPostModal from "../components/NewPost.tsx" const Feed: React.FC = () => { - const navigate = useNavigate(); - const [posts, setPosts] = useState([]); - const [commentsCount, setCommentsCount] = useState<{ [key: string]: number }>({}); - const [likesCount, setLikesCount] = useState<{ [key: string]: number }>({}); - const [isLikedByUser, setIsLikedByUser] = useState<{ [key: string]: boolean }>({}); // Track if the current user liked each post - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [openDialog, setOpenDialog] = useState(false); - const [postIdToDelete, setPostIdToDelete] = useState(null); - const [filterByUser, setFilterByUser] = useState(false); - const [profileImages, setProfileImages] = useState<{ [key: string]: string }>({}); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [showNewPostModal, setShowNewPostModal] = useState(false); - - const auth = getUserAuth(); + const navigate = useNavigate() + const theme = useTheme() + const [posts, setPosts] = useState([]) + const [commentsCount, setCommentsCount] = useState<{ [key: string]: number }>({}) + const [likesCount, setLikesCount] = useState<{ [key: string]: number }>({}) + const [isLikedByUser, setIsLikedByUser] = useState<{ [key: string]: boolean }>({}) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [openDialog, setOpenDialog] = useState(false) + const [postIdToDelete, setPostIdToDelete] = useState(null) + const [filterByUser, setFilterByUser] = useState(false) + const [profileImages, setProfileImages] = useState<{ [key: string]: string }>({}) + const [currentPage, setCurrentPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [showNewPostModal, setShowNewPostModal] = useState(false) + const [showResumePostModal, setShowResumePostModal] = useState(false) + const [savedPosts, setSavedPosts] = useState([]) + + const auth = getUserAuth() const handleCreatePost = () => { - setShowNewPostModal(true); - }; + setShowNewPostModal(true) + } + + const handleShareResume = () => { + setShowResumePostModal(true) + } const handleDeletePost = async () => { if (postIdToDelete) { @@ -58,313 +81,732 @@ const Feed: React.FC = () => { headers: { Authorization: `Bearer ${auth.accessToken}`, }, - }); - setPosts(posts.filter(post => post.id !== postIdToDelete)); - setOpenDialog(false); - setPostIdToDelete(null); + }) + setPosts(posts.filter((post) => post.id !== postIdToDelete)) + setOpenDialog(false) + setPostIdToDelete(null) } catch (err) { - console.error("Failed to delete post:", err); + console.error("Failed to delete post:", err) } } - }; + } - const handleOpenDialog = (e: React.FormEvent, postId: string) => { - e.stopPropagation(); - setPostIdToDelete(postId); - setOpenDialog(true); - }; + const handleOpenDialog = (e: React.MouseEvent, postId: string) => { + e.stopPropagation() + setPostIdToDelete(postId) + setOpenDialog(true) + } const handleCloseDialog = () => { - setOpenDialog(false); - setPostIdToDelete(null); - }; + setOpenDialog(false) + setPostIdToDelete(null) + } const fetchProfileImage = async (imageFilename: string | null) => { try { if (!imageFilename) { - return defaultProfileImage; + return defaultProfileImage + } + if (imageFilename.startsWith("https://")) { + return imageFilename } const response = await api.get(`/resource/image/${imageFilename}`, { - responseType: 'blob', - }); - return URL.createObjectURL(response.data as Blob); + responseType: "blob", + }) + return URL.createObjectURL(response.data as Blob) } catch (error) { - console.error('Error fetching profile image:', error); - return defaultProfileImage; + console.error("Error fetching profile image:", error) + return defaultProfileImage } - }; + } - const handleLikePost = async (e: React.FormEvent, postId: string) => { - e.stopPropagation(); + const handleLikePost = async (e: React.MouseEvent, postId: string) => { + e.stopPropagation() try { - const value = !isLikedByUser[postId]; // Toggle the like state locally + const value = !isLikedByUser[postId] await api.put( `/post/${postId}/like`, - { value }, // Send true to add a like, false to remove it + { value }, { headers: { Authorization: `Bearer ${auth.accessToken}`, }, - } - ); + }, + ) - // Update the likes count and isLikedByUser state locally setLikesCount((prev) => ({ ...prev, [postId]: value ? (prev[postId] || 0) + 1 : Math.max((prev[postId] || 0) - 1, 0), - })); + })) setIsLikedByUser((prev) => ({ ...prev, [postId]: value, - })); + })) } catch (err) { - console.error("Failed to toggle like for post:", err); + console.error("Failed to toggle like for post:", err) } - }; + } + + const toggleSavePost = (e: React.MouseEvent, postId: string) => { + e.stopPropagation() + setSavedPosts((prev) => (prev.includes(postId) ? prev.filter((id) => id !== postId) : [...prev, postId])) + } + + + const formatDate = (dateString: string) => { + if (!dateString) return "" + + const date = new Date(dateString) + const now = new Date() + const diffTime = Math.abs(now.getTime() - date.getTime()) + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + + if (diffDays === 0) { + const diffHours = Math.floor(diffTime / (1000 * 60 * 60)) + if (diffHours === 0) { + const diffMinutes = Math.floor(diffTime / (1000 * 60)) + return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago` + } + return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago` + } else if (diffDays < 7) { + return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago` + } else { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }) + } + } const loadPosts = async (page: number) => { try { - setIsLoading(true); - setError(null); + setIsLoading(true) + setError(null) const response = await api.get(`/post`, { params: { page, - limit: 5, // Number of posts per page + limit: 5, owner: filterByUser ? auth.userId : undefined, }, - }); - const { posts: postsData, totalPages: total, currentPage: current } = response.data; - setPosts(postsData); - setTotalPages(total); - setCurrentPage(current); + }) + const { posts: postsData, totalPages: total, currentPage: current } = response.data + setPosts(postsData) + setTotalPages(total) + setCurrentPage(current) // Fetch profile images for each post owner - const images: { [key: string]: string } = {}; + const images: { [key: string]: string } = {} await Promise.all( postsData.map(async (post: Post) => { - const imageUrl = await fetchProfileImage(post.ownerProfileImage as string); - images[post.owner] = imageUrl; - }) - ); - setProfileImages(images); + const imageUrl = await fetchProfileImage(post.ownerProfileImage as string) + images[post.owner] = imageUrl + }), + ) + setProfileImages(images) // Fetch comments count for each post - const commentsCountData: { [key: string]: number } = {}; + const commentsCountData: { [key: string]: number } = {} await Promise.all( postsData.map(async (post: Post) => { - const commentsResponse = await api.get(`/comment/post/${post.id}`); - commentsCountData[post.id] = (commentsResponse.data as Comment[]).length; - }) - ); - setCommentsCount(commentsCountData); + const commentsResponse = await api.get(`/comment/post/${post.id}`) + commentsCountData[post.id] = (commentsResponse.data as Comment[]).length + }), + ) + setCommentsCount(commentsCountData) // Fetch likes count and determine if the user has liked each post - const likesCountData: { [key: string]: number } = {}; - const isLikedByUserData: { [key: string]: boolean } = {}; + const likesCountData: { [key: string]: number } = {} + const isLikedByUserData: { [key: string]: boolean } = {} await Promise.all( postsData.map(async (post: Post) => { try { - const likesResponse = await api.get(`/post/${post.id}/like`); - likesCountData[post.id] = likesResponse.data.count; - isLikedByUserData[post.id] = likesResponse.data.likedBy.includes(auth.userId); // Check if the current user liked the post + const likesResponse = await api.get(`/post/${post.id}/like`) + likesCountData[post.id] = likesResponse.data.count + isLikedByUserData[post.id] = likesResponse.data.likedBy.includes(auth.userId) } catch (error) { - console.error(`Failed to fetch likes for post ${post.id}:`, error); - likesCountData[post.id] = 0; // Default to 0 likes if there's an error - isLikedByUserData[post.id] = false; // Default to not liked + console.error(`Failed to fetch likes for post ${post.id}:`, error) + likesCountData[post.id] = 0 + isLikedByUserData[post.id] = false } - }) - ); - setLikesCount(likesCountData); - setIsLikedByUser(isLikedByUserData); + }), + ) + setLikesCount(likesCountData) + setIsLikedByUser(isLikedByUserData) } catch (err) { - setError("Failed to load posts. Please try again later."); - console.error("Failed to load posts:", err); + setError("Failed to load posts. Please try again later.") + console.error("Failed to load posts:", err) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } useEffect(() => { - loadPosts(currentPage); - }, [currentPage, filterByUser]); + loadPosts(currentPage) + }, [currentPage, filterByUser]) const handlePageChange = (newPage: number) => { if (newPage > 0 && newPage <= totalPages) { - setCurrentPage(newPage); + setCurrentPage(newPage) } - }; + } + + const renderSkeletons = () => { + return Array(3) + .fill(0) + .map((_, index) => ( + + + + + + + + + + + + + + + + + + + )) + } return ( - - - - - - + + + + + + What's on your mind{auth.username ? `, ${auth.username}` : ""}? + + - - - - - - setFilterByUser(!filterByUser)} - color="primary" - /> - } - label="Show only my posts" - /> - setShowNewPostModal(false)} - onPostCreated={() => loadPosts(currentPage)} - /> - - {isLoading ? ( - - - - ) : error ? ( - {error} - ) : ( - <> - - {posts.map((post) => ( - - navigate(`/post/${post.id}`)}> - - - - {post.ownerUsername || post.owner} - - - {post.title} - - - - - - - handleLikePost(e, post.id)} - color={isLikedByUser[post.id] ? 'primary' : 'default'} - > - - - - - - - - - - {post.owner === auth.userId && ( - handleOpenDialog(e, post.id)}> - - - )} - - - - ))} - - - )} - - { totalPages > 0 && - - {`Page ${currentPage} of ${totalPages}`} - - - } - + + + + + + + + + + + + - + - {"Are you sure you want to delete this post?"} - - - This action cannot be undone. - - - - - - - + + ) : ( + <> + {posts.map((post, index) => ( + navigate(`/post/${post.id}`)} + > + + + + + + + {post.ownerUsername || post.owner} + + + {post.createdAt && formatDate(post.createdAt)} + + + + + + {post.owner === auth.userId && ( + + handleOpenDialog(e, post.id)} + sx={{ + color: theme.palette.text.secondary, + "&:hover": { + color: theme.palette.error.main, + backgroundColor: alpha(theme.palette.error.main, 0.1), + }, + }} + > + + + + )} + + + + + {post.title} + + + + + + + + + + + + handleLikePost(e, post.id)} + color={isLikedByUser[post.id] ? "primary" : "default"} + size="small" + sx={{ + transition: "transform 0.2s", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + + + + + + + + + + + + + + + toggleSavePost(e, post.id)} + size="small" + color={savedPosts.includes(post.id) ? "primary" : "default"} + sx={{ + transition: "transform 0.2s", + "&:hover": { transform: "scale(1.1)" }, + }} + > + {savedPosts.includes(post.id) ? ( + + ) : ( + + )} + + + + + navigate(`/post/${post.id}`)} + /> + + + ))} + + )} + + {!isLoading && posts.length > 0 && totalPages > 0 && ( + + + + + {`Page ${currentPage} of ${totalPages}`} + + + + + )} + + + Delete Post + + + This action cannot be undone. Are you sure you want to delete this post? + + + + + + + - ); -}; + ) +} -export default Feed; \ No newline at end of file +export default Feed diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 45012f6..68e6c8d 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import type React from "react" +import { useState, useEffect } from "react" import { Container, Grid, @@ -9,7 +10,6 @@ import { Chip, Stack, Avatar, - Divider, Autocomplete, FormControl, InputLabel, @@ -17,452 +17,1137 @@ import { MenuItem, CircularProgress, IconButton, - Tooltip -} from '@mui/material'; + Tooltip, + useTheme, + alpha, + Card, + CardContent, + LinearProgress, + Alert, +} from "@mui/material" import { GitHub, - LinkedIn, Person as PersonIcon, Work as WorkIcon, Build as BuildIcon, - DocumentScannerTwoTone, LightbulbSharp, - Grading -} from '@mui/icons-material'; -import { - connectToGitHub, - initiateGitHubOAuth, - fetchRepoLanguages, - handleGitHubOAuth -} from '../handlers/githubAuth'; -import api from '../serverApi'; -import LinkedinJobs from '../components/LinkedinJobs'; + Grading, + Add as AddIcon, + TrendingUp, + Star, + Code, + Business, + CheckCircle, + InsertDriveFile, + Delete, + Sync, +} from "@mui/icons-material" +import { connectToGitHub, initiateGitHubOAuth, fetchRepoLanguages, handleGitHubOAuth } from "../handlers/githubAuth" +import api from "../serverApi" +import LinkedinJobs from "../components/LinkedinJobs" +import { motion } from "framer-motion" +import { getUserAuth } from "../handlers/userAuth" const roles = [ - 'Software Engineer', 'Frontend Developer', 'Backend Developer', - 'Full Stack Developer', 'DevOps Engineer', 'Product Manager', 'UI/UX Designer' -]; + "Software Engineer", + "Frontend Developer", + "Backend Developer", + "Full Stack Developer", + "DevOps Engineer", + "Product Manager", + "UI/UX Designer", +] const skillsList = [ - 'React', 'JavaScript', 'TypeScript', 'Python', 'Java', 'Node.js', - 'Express', 'MongoDB', 'AWS', 'Docker', 'Kubernetes', 'Git', 'Agile' -]; + "React", + "JavaScript", + "TypeScript", + "Python", + "Java", + "Node.js", + "Express", + "MongoDB", + "AWS", + "Docker", + "Kubernetes", + "Git", + "Agile", +] const MainDashboard: React.FC = () => { - const [aboutMe, setAboutMe] = useState(() => localStorage.getItem('aboutMe') || ''); - const [skills, setSkills] = useState(() => JSON.parse(localStorage.getItem('skills') || '[]')); - const [newSkill, setNewSkill] = useState(''); - const [selectedRole, setSelectedRole] = useState(() => localStorage.getItem('selectedRole') || ''); - const [repos, setRepos] = useState<{ id: number; name: string; html_url: string }[]>([]); - const [useOAuth, setUseOAuth] = useState(true); - const [showAuthOptions, setShowAuthOptions] = useState(false); + const theme = useTheme() + const [aboutMe, setAboutMe] = useState("") + const [skills, setSkills] = useState([]) + const [newSkill, setNewSkill] = useState("") + const [selectedRole, setSelectedRole] = useState("") + const [repos, setRepos] = useState<{ id: number; name: string; html_url: string }[]>([]) + const [useOAuth, setUseOAuth] = useState(true) + const [showAuthOptions, setShowAuthOptions] = useState(false) // AI-resume state - const [parsing, setParsing] = useState(false); - const [resumeExperience, setResumeExperience] = useState([]); - const [roleMatch, setRoleMatch] = useState(''); - const [resumeFileName, setResumeFileName] = useState(''); + const [parsing, setParsing] = useState(false) + const [syncing, setSyncing] = useState(false) + const [resumeExperience, setResumeExperience] = useState([]) + const [roleMatch, setRoleMatch] = useState("") + const [resumeFileName, setResumeFileName] = useState("") + const [currentResumeId, setCurrentResumeId] = useState("") + const [hasResumeChanged, setHasResumeChanged] = useState(false) + + // Profile image state + const [image, setImage] = useState(null) // Skills toggle - const [showAllSkills, setShowAllSkills] = useState(false); - const SKILL_DISPLAY_LIMIT = 6; - const shouldShowToggle = skills.length > SKILL_DISPLAY_LIMIT; + const [showAllSkills, setShowAllSkills] = useState(false) + const SKILL_DISPLAY_LIMIT = 4 + const shouldShowToggle = skills.length > SKILL_DISPLAY_LIMIT // LinkedIn jobs state - const [jobs, setJobs] = useState<{ position: string; company: string; location: string; url: string, companyLogo?: string, jobUrl?: string }[]>([]); - const [loadingJobs, setLoadingJobs] = useState(false); // New state for loading jobs + const [jobs, setJobs] = useState< + { position: string; company: string; location: string; url: string; companyLogo?: string; jobUrl?: string }[] + >([]) + const [loadingJobs, setLoadingJobs] = useState(false) // Job Recommendations toggle - const [showJobRecommendations, setShowJobRecommendations] = useState(false); // New state for toggle + const [showJobRecommendations, setShowJobRecommendations] = useState(false) const toggleJobRecommendations = () => { - setShowJobRecommendations(!showJobRecommendations); - }; - - // Persist to localStorage - useEffect(() => { localStorage.setItem('aboutMe', aboutMe); }, [aboutMe]); - useEffect(() => { localStorage.setItem('skills', JSON.stringify(skills)); }, [skills]); - useEffect(() => { localStorage.setItem('selectedRole', selectedRole); }, [selectedRole]); + setShowJobRecommendations(!showJobRecommendations) + } // Handle GitHub OAuth callback useEffect(() => { - const code = new URLSearchParams(window.location.search).get('code'); + const code = new URLSearchParams(window.location.search).get("code") if (code) { - (async () => { + ;(async () => { try { - const username = await handleGitHubOAuth(code); - const fetched = await connectToGitHub(username); - setRepos(fetched); - mergeRepoLanguages(fetched); + const username = await handleGitHubOAuth(code) + const fetched = await connectToGitHub(username) + setRepos(fetched) + mergeRepoLanguages(fetched) } catch (e) { - console.error(e); + console.error(e) } - })(); + })() + } + }, []) + + // Fetch resume data on mount and check for changes + useEffect(() => { + const fetchResumeData = async () => { + try { + const user_data = await api.get("/user/" + getUserAuth().userId); + if (user_data.data.aboutMe) { + setAboutMe(user_data.data.aboutMe || "") + setSkills(user_data.data.skills || []) + setSelectedRole(user_data.data.selectedRole || "") + } + + const response = await api.get("/resume") + if (response.data && response.data.parsedData) { + const parsedData = response.data.parsedData + const storedResumeId = localStorage.getItem("lastResumeId") + const currentId = parsedData.id || response.data.rawContentLink + + setResumeFileName(parsedData.fileName || "") + setResumeExperience(parsedData.experience || []) + setRoleMatch(parsedData.roleMatch || "") + setCurrentResumeId(currentId) + + // Check if resume has changed + if (storedResumeId && storedResumeId !== currentId) { + setHasResumeChanged(true) + } else { + localStorage.setItem("lastResumeId", currentId) + } + } + } catch (err) { + console.error("Failed to fetch resume data:", err) + } } - }, []); + fetchResumeData() + }, []) const mergeRepoLanguages = async (fetchedRepos: typeof repos) => { - const langSet = new Set(skills); + const langSet = new Set(skills) for (const repo of fetchedRepos) { - const langs = await fetchRepoLanguages(repo.html_url); - Object.keys(langs).forEach(lang => langSet.add(lang)); + const langs = await fetchRepoLanguages(repo.html_url) + Object.keys(langs).forEach((lang) => langSet.add(lang)) } - setSkills(Array.from(langSet)); - }; + setSkills(Array.from(langSet)) + } const handleAddSkill = (skill: string) => { - const trimmed = skill.trim(); - if (!trimmed || skills.includes(trimmed)) return; - setSkills(prev => [trimmed, ...prev]); - setNewSkill(''); - }; + const trimmed = skill.trim() + if (!trimmed || skills.includes(trimmed)) return + setSkills((prev) => [trimmed, ...prev]) + setIsProfileDirty(true); + setNewSkill("") + } const handleDeleteSkill = (skillToDelete: string) => { - setSkills(prev => prev.filter(s => s !== skillToDelete)); - }; + setSkills((prev) => prev.filter((s) => s !== skillToDelete)) + setIsProfileDirty(true); + } const handleGitHubConnect = async () => { - if (!showAuthOptions) return setShowAuthOptions(true); + if (!showAuthOptions) return setShowAuthOptions(true) try { - if (useOAuth) initiateGitHubOAuth(); + if (useOAuth) initiateGitHubOAuth() else { - const username = prompt('Enter GitHub username:'); - if (!username) return alert('Username required'); - const fetched = await connectToGitHub(username); - setRepos(fetched); - mergeRepoLanguages(fetched); + const username = prompt("Enter GitHub username:") + if (!username) return alert("Username required") + const fetched = await connectToGitHub(username) + setRepos(fetched) + mergeRepoLanguages(fetched) } } catch (e) { - console.error(e); + console.error(e) } finally { - setShowAuthOptions(false); + setShowAuthOptions(false) } - }; + } + + useEffect(() => { + const fetchProfileImage = async () => { + try { + const response = await api.get(`/resource/image/${getUserAuth().imageFilename}`, { + responseType: "blob", + }) + const imageUrl = URL.createObjectURL(response.data as Blob) + setImage(imageUrl) + } catch (error) { + console.log("Error fetching profile image.") + setImage(null) + } + } + + getUserAuth().imageFilename && fetchProfileImage() + }, []) // Upload & parse resume const handleResumeUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setResumeFileName(file.name); - setParsing(true); - const form = new FormData(); - form.append('resume', file); + const uploadResume = async (formData: FormData) => { + const response = await api.post("/resource/resume", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) + return response.data + } + + const file = e.target.files?.[0] + if (!file) return + setResumeFileName(file.name) + setParsing(true) + const form = new FormData() + form.append("file", file) try { - const res = await api.post('/resume/parseResume', form, { - headers: { 'Content-Type': 'multipart/form-data' } - }); - const { aboutMe: aiAbout, skills: aiSkills, roleMatch: aiRole, experience: aiExp } = res.data; - setAboutMe(aiAbout); - setSkills(aiSkills); - setRoleMatch(aiRole); - setResumeExperience(aiExp); + const uploadedResume = await uploadResume(form) + const res = await api.post("/resume/parseResume", { + resumefileName: uploadedResume, + originfilename: file.name, + }) + const { aboutMe: aiAbout, skills: aiSkills, roleMatch: aiRole, experience: aiExp } = res.data + setAboutMe(aiAbout) + setSkills(aiSkills) + setRoleMatch(aiRole) + setResumeExperience(aiExp) + setCurrentResumeId(uploadedResume) + localStorage.setItem("lastResumeId", uploadedResume) + updateUserProfile(aiAbout, aiSkills, selectedRole); + setHasResumeChanged(false) } catch (err) { - console.error(err); - alert('Failed to parse resume.'); + console.error(err) + alert("Failed to parse resume.") } finally { - setParsing(false); + setParsing(false) } - }; + } + + // Sync with new resume + const handleSyncWithNewResume = async () => { + if (!currentResumeId) return + + setSyncing(true) + try { + const res = await api.post("/resume/parseResume", { + resumefileName: currentResumeId, + originfilename: resumeFileName, + }) + const { aboutMe: aiAbout, skills: aiSkills, roleMatch: aiRole, experience: aiExp } = res.data + setAboutMe(aiAbout) + setSkills(aiSkills) + setRoleMatch(aiRole) + setResumeExperience(aiExp) + localStorage.setItem("lastResumeId", currentResumeId) + setHasResumeChanged(false) + updateUserProfile(aiAbout, aiSkills, selectedRole); + } catch (err) { + console.error(err) + alert("Failed to sync with resume.") + } finally { + setSyncing(false) + } + } // Fetch Linkedin Jobs - const fetchJobs = async (settings: { location: string; dateSincePosted: string; jobType: string; experienceLevel: string; skills?: string[] }) => { - setLoadingJobs(true); + const fetchJobs = async (settings: { + location: string + dateSincePosted: string + jobType: string + experienceLevel: string + skills?: string[] + }) => { + setLoadingJobs(true) try { - const response = await api.get('/linkedin-jobs/jobs', { + const response = await api.get("/linkedin-jobs/jobs", { params: { - skills: (settings.skills || skills.slice(0, 3)).join(','), // Use updated skills or default to first three + skills: (settings.skills || skills.slice(0, 3)).join(","), role: selectedRole, location: settings.location, dateSincePosted: settings.dateSincePosted, jobType: settings.jobType, experienceLevel: settings.experienceLevel, }, + }) + setJobs(response.data) + } catch (error) { + console.error("Error fetching jobs:", error) + } finally { + setLoadingJobs(false) + } + } + + // Calculate profile completion + const calculateProfileCompletion = () => { + let completed = 0 + const total = 4 + if (aboutMe.trim()) completed++ + if (selectedRole.trim()) completed++ + if (skills.length > 0) completed++ + if (repos.length > 0) completed++ + return Math.round((completed / total) * 100) + } + + const profileCompletion = calculateProfileCompletion() + + const handleRemoveAllSkills = () => { + setSkills([]) + setIsProfileDirty(true); + } + + const [isProfileDirty, setIsProfileDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + + useEffect(() => { + setIsProfileDirty(true); + }, [aboutMe, skills, selectedRole]); + + const handleAboutMeChange = (e: React.ChangeEvent) => { + setAboutMe(e.target.value); + setIsProfileDirty(true); + }; + + const handleSelectedRoleChange = (_: any, val: string | null) => { + setSelectedRole(val || ""); + setIsProfileDirty(true); + }; + + const updateUserProfile = async (newAboutMe?: string, newSkills?: string, newSelectedRole?: string) => { + setIsSaving(true); + try { + await api.put(`/user/${getUserAuth().userId}`, { + aboutMe: newAboutMe ? newAboutMe : aboutMe, + skills: newSkills ? newSkills : skills, + selectedRole: newSelectedRole ? newSelectedRole : selectedRole, + }, { + headers: { + Authorization: `Bearer ${getUserAuth().accessToken}`, + }, }); - setJobs(response.data); + // // Optionally, update localStorage or context with new values + // localStorage.setItem("aboutMe", aboutMe); + // localStorage.setItem("skills", JSON.stringify(skills)); + // localStorage.setItem("selectedRole", selectedRole); + // setIsProfileDirty(false); } catch (error) { - console.error('Error fetching jobs:', error); + console.error("Failed to update profile:", error); + alert("Failed to update profile. Please try again."); } finally { - setLoadingJobs(false); + setIsSaving(false); } }; return ( - - - {/* Left Column */} - {/* Adjusted width */} - - {/* About Me Section */} - - {/* Upload icon & filename above the title, right-aligned */} - - - - - - - {resumeFileName && ( - + + {/* Welcome Header */} + + + + NextStep + + + Your personalized career development dashboard + + + {/* Resume Change Alert */} + {hasResumeChanged && ( + : } + onClick={handleSyncWithNewResume} + disabled={syncing} > - {resumeFileName} - - )} - {parsing && } - - - {/* Header */} - - - - About Me - - - - {/* Content */} - setAboutMe(e.target.value)} - /> - - - {/* Desired Role */} - - - - - Desired Role - - - setSelectedRole(val)} - renderInput={params => ( - - )} - /> - - - {/* Skills */} - - - - - Skills - - - - - {(showAllSkills ? skills : skills.slice(0, SKILL_DISPLAY_LIMIT)).map(skill => ( - handleDeleteSkill(skill)} /> - ))} - - {shouldShowToggle && ( - - )} - - setNewSkill(val)} - onChange={(_, val) => val && handleAddSkill(val)} - renderInput={params => ( - e.key === 'Enter' && handleAddSkill(newSkill)} - /> - )} - sx={{ flexGrow: 1 }} - /> - - - - - {/* Suggested Role Match */} - {roleMatch && ( - - - - - Suggested Role Match - - - {roleMatch} - + {syncing ? "Syncing..." : "Sync Now"} + + } + > + Your resume has been updated. Click "Sync Now" to update your profile with the latest information. + )} - {/* Experience */} - {resumeExperience.length > 0 && ( - - - - - Experience + {/* Profile Completion Card */} + + + + + Profile Completion + + + {profileCompletion}% - - {resumeExperience.map((exp, i) => ( - {exp} - ))} - - - )} - - - - {/* Right Column */} - - - - - Connect Accounts - - - {showAuthOptions ? ( - - - Method - - - - - - ) : ( - - - - - )} + + {/* Upload Section */} + + + {!image ? ( + + + + ) : ( + + )} + + + About Me + + + Tell us about yourself and your career goals + + + - {repos.length > 0 && ( - - - Repositories: - - - {repos.map(repo => ( - - ))} - - - )} - + + + + + + + + Skills ({skills.length}) + + + Your technical expertise + + + + + + + + {(showAllSkills ? skills : skills.slice(0, SKILL_DISPLAY_LIMIT)).map((skill, index) => ( + handleDeleteSkill(skill)} + size="small" + sx={{ + transition: "all 0.2s ease", + "&:hover": { + transform: "scale(1.05)", + boxShadow: `0 2px 8px ${alpha(theme.palette.info.main, 0.3)}`, + }, + backgroundColor: alpha(theme.palette.info.main, 0.1), + color: theme.palette.info.main, + "& .MuiChip-deleteIcon": { + color: alpha(theme.palette.info.main, 0.7), + "&:hover": { + color: theme.palette.error.main, + }, + }, + }} + /> + ))} + + + {shouldShowToggle && ( + + )} + + + setNewSkill(val)} + onChange={(_, val) => val && handleAddSkill(val)} + renderInput={(params) => ( + e.key === "Enter" && handleAddSkill(newSkill)} + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: 2, + backgroundColor: alpha(theme.palette.background.default, 0.5), + }, + }} + /> + )} + sx={{ flexGrow: 1 }} + /> + handleAddSkill(newSkill)} + disabled={!newSkill.trim()} + sx={{ + bgcolor: alpha(theme.palette.info.main, 0.1), + color: theme.palette.info.main, + "&:hover": { + bgcolor: alpha(theme.palette.info.main, 0.2), + }, + }} + > + + + + + + + + + {/* Desired Role */} + + + + + + + + + + + Target Role + + + What's your dream job? + + + + ( + + )} + /> + + + + + + + {/* AI Insights Row */} + {(roleMatch || resumeExperience.length > 0) && ( + + {/* Suggested Role Match */} + {roleMatch && ( + + + + + + + + + + + AI Suggestion + + + Based on your profile + + + + + "{roleMatch}" + + + + + + )} + + {/* Experience */} + {resumeExperience.length > 0 && ( + + + + + + + + + + + Experience Highlights + + + From your resume + + + + + {resumeExperience.slice(0, 3).map((exp, i) => ( + + + {exp} + + ))} + + + + + + )} + + )} + + + + {/* Right Column */} + + + {/* Quick Stats */} + + + + + + + + {skills.length} + + + Skills + + + + + + + + + + {repos.length} + + + Repos + + + + + + + + {/* Connect Accounts */} + + + + + + + + + + Connect Accounts + + + Link your professional profiles + + + - {/* Jobs Section */} - + {showAuthOptions ? ( + + + Connection Method + + + + + + + + ) : ( + + + + )} + + {repos.length > 0 && ( + + + Connected Repositories ({repos.length}) + + + {repos.slice(0, 5).map((repo) => ( + + ))} + {repos.length > 5 && ( + + +{repos.length - 5} more repositories + + )} + + + )} + + + + + {/* Jobs Section */} + + + + + - - - ); -}; + {isProfileDirty && ( + + )} + + + ) +} -export default MainDashboard; +export default MainDashboard diff --git a/nextstep-frontend/src/pages/PostDetails.tsx b/nextstep-frontend/src/pages/PostDetails.tsx index 51a0f91..4184844 100644 --- a/nextstep-frontend/src/pages/PostDetails.tsx +++ b/nextstep-frontend/src/pages/PostDetails.tsx @@ -1,110 +1,161 @@ -import React, { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { Container, Typography, Box, Paper, CircularProgress, Button, Avatar, Divider, IconButton, Collapse, Badge, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField } from '@mui/material'; -import { ArrowBack, Comment as CommentIcon, ThumbUp as ThumbUpIcon, Delete as DeleteIcon, Edit as EditIcon, Check as CheckIcon, Cancel as CancelIcon } from '@mui/icons-material'; -import FroalaEditor from 'react-froala-wysiwyg'; -import 'froala-editor/css/froala_style.min.css'; -import 'froala-editor/css/froala_editor.pkgd.min.css'; -import 'froala-editor/js/plugins/image.min.js'; -import { Post as PostModel } from '../models/Post'; -import { Comment } from '../models/Comment'; -import { getUserAuth } from "../handlers/userAuth.ts"; -import api from "../serverApi.ts"; -import defaultProfileImage from '../../assets/defaultProfileImage.jpg'; -import { config } from '../config.ts'; +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { useParams, useNavigate } from "react-router-dom" +import { + Container, + Typography, + Box, + Paper, + Button, + Avatar, + Divider, + IconButton, + Collapse, + Badge, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, + useTheme, + alpha, + Tooltip, + Chip, + Skeleton, +} from "@mui/material" +import { + ArrowBack, + Comment as CommentIcon, + ThumbUp as ThumbUpIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Check as CheckIcon, + Cancel as CancelIcon, + Share as ShareIcon, + Bookmark as BookmarkIcon, + BookmarkBorder as BookmarkBorderIcon, +} from "@mui/icons-material" +import FroalaEditor from "react-froala-wysiwyg" +import "froala-editor/css/froala_style.min.css" +import "froala-editor/css/froala_editor.pkgd.min.css" +import "froala-editor/js/plugins/image.min.js" +import type { Post as PostModel } from "../models/Post" +import type { Comment } from "../models/Comment" +import { getUserAuth } from "../handlers/userAuth.ts" +import api from "../serverApi.ts" +import defaultProfileImage from "../../assets/defaultProfileImage.jpg" +import { config } from "../config.ts" +import { motion } from "framer-motion" const PostDetails: React.FC = () => { - const { postId } = useParams<{ postId: string }>(); - const navigate = useNavigate(); - const [post, setPost] = useState(null); - const [comments, setComments] = useState([]); - const [commentProfileImages, setCommentProfileImages] = useState<{ [key: string]: string }>({}); - const [newComment, setNewComment] = useState(''); // Froala Editor content for the new comment - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [commentsOpen, setCommentsOpen] = useState(false); - const [isEditing, setIsEditing] = useState(false); - const [editedTitle, setEditedTitle] = useState(''); - const [editedContent, setEditedContent] = useState(''); // Froala Editor content for editing the post - const [openDialog, setOpenDialog] = useState(false); - const [isLiked, setIsLiked] = useState(false); // State for like icon - const [likesCount, setLikesCount] = useState(0); // State for likes count - const [profileImage, setProfileImage] = useState(defaultProfileImage); // State for profile image - const auth = getUserAuth(); + const { postId } = useParams<{ postId: string }>() + const navigate = useNavigate() + const theme = useTheme() + const [post, setPost] = useState(null) + const [comments, setComments] = useState([]) + const [commentProfileImages, setCommentProfileImages] = useState<{ [key: string]: string }>({}) + const [newComment, setNewComment] = useState("") + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [commentsOpen, setCommentsOpen] = useState(true) + const [isEditing, setIsEditing] = useState(false) + const [editedTitle, setEditedTitle] = useState("") + const [editedContent, setEditedContent] = useState("") + const [openDialog, setOpenDialog] = useState(false) + const [isLiked, setIsLiked] = useState(false) + const [likesCount, setLikesCount] = useState(0) + const [profileImage, setProfileImage] = useState(defaultProfileImage) + const [isSaved, setIsSaved] = useState(false) + const [isSubmittingComment, setIsSubmittingComment] = useState(false) + const auth = getUserAuth() const fetchProfileImage = async (imageFilename: string | null) => { try { if (!imageFilename) { - return defaultProfileImage; + return defaultProfileImage } - + // If it's a Google profile image URL, return it directly - if (imageFilename.startsWith('https://')) { - return imageFilename; + if (imageFilename.startsWith("https://")) { + return imageFilename } - + // Otherwise, fetch from our backend const response = await api.get(`/resource/image/${imageFilename}`, { - responseType: 'blob', - }); - return URL.createObjectURL(response.data as Blob); + responseType: "blob", + }) + return URL.createObjectURL(response.data as Blob) } catch (error) { - console.error('Error fetching profile image:', error); - return defaultProfileImage; + console.error("Error fetching profile image:", error) + return defaultProfileImage } - }; + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + } const loadPostDetails = async () => { try { - setIsLoading(true); - setError(null); - const postResponse = await api.get(`/post/${postId}`); - const postData = postResponse.data as PostModel; - setPost(postData); - setEditedTitle(postData.title); - setEditedContent(postData.content); - + setIsLoading(true) + setError(null) + const postResponse = await api.get(`/post/${postId}`) + const postData = postResponse.data as PostModel + setPost(postData) + setEditedTitle(postData.title) + setEditedContent(postData.content) + // Fetch profile image for the post owner - const imageUrl = await fetchProfileImage(postData.ownerProfileImage as string); - setProfileImage(imageUrl); - + const imageUrl = await fetchProfileImage(postData.ownerProfileImage as string) + setProfileImage(imageUrl) + // Fetch likes count and determine if the user has liked the post try { - const likesResponse = await api.get(`/post/${postId}/like`); - setLikesCount(likesResponse.data.count); - setIsLiked(likesResponse.data.likedBy.includes(auth.userId)); // Check if the current user is in the likedBy array + const likesResponse = await api.get(`/post/${postId}/like`) + setLikesCount(likesResponse.data.count) + setIsLiked(likesResponse.data.likedBy.includes(auth.userId)) } catch (err) { - console.error('Failed to fetch likes:', err); + console.error("Failed to fetch likes:", err) } - + // Fetch comments try { - const commentsResponse = await api.get(`/comment/post/${postId}`); - const commentsData = commentsResponse.data as Comment[]; - setComments(commentsData); - + const commentsResponse = await api.get(`/comment/post/${postId}`) + const commentsData = commentsResponse.data as Comment[] + setComments(commentsData) + // Fetch profile images for each comment owner - const images: { [key: string]: string } = {}; + const images: { [key: string]: string } = {} await Promise.all( commentsData.map(async (comment) => { - const imageUrl = await fetchProfileImage(comment.ownerProfileImage as string); - images[comment.id] = imageUrl; - }) - ); - setCommentProfileImages(images); + const imageUrl = await fetchProfileImage(comment.ownerProfileImage as string) + images[comment.id] = imageUrl + }), + ) + setCommentProfileImages(images) } catch (err) { - console.log('Failed to load comments:', err); + console.log("Failed to load comments:", err) } } catch (err) { - setError('Failed to load post details. Please try again later.'); + setError("Failed to load post details. Please try again later.") } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } const handleLikeToggle = async () => { try { - const value = !isLiked; // Toggle the like state + const value = !isLiked await api.put( `/post/${postId}/like`, { value }, @@ -112,369 +163,837 @@ const PostDetails: React.FC = () => { headers: { Authorization: `Bearer ${auth.accessToken}`, }, - } - ); - setIsLiked(value); // Update the like state - setLikesCount((prev) => (value ? prev + 1 : Math.max(prev - 1, 0))); // Update the likes count + }, + ) + setIsLiked(value) + setLikesCount((prev) => (value ? prev + 1 : Math.max(prev - 1, 0))) } catch (err) { - console.error('Failed to toggle like:', err); + console.error("Failed to toggle like:", err) } - }; + } const handleAddComment = async () => { + if (!newComment.trim()) return + try { + setIsSubmittingComment(true) const response = await api.post(`/comment`, { content: newComment, postId, - }); - const newCommentData = response.data as Comment; - setComments([...comments, newCommentData]); + }) + const newCommentData = response.data as Comment // Fetch profile image for the new comment owner - const imageUrl = await fetchProfileImage(newCommentData.ownerProfileImage as string); + const imageUrl = await fetchProfileImage(newCommentData.ownerProfileImage as string) setCommentProfileImages((prev) => ({ ...prev, [newCommentData.id]: imageUrl, - })); + })) - setNewComment(''); // Clear the Froala Editor content + setComments([...comments, newCommentData]) + setNewComment("") } catch (err) { - console.error('Failed to add comment:', err); + console.error("Failed to add comment:", err) + } finally { + setIsSubmittingComment(false) } - }; + } const handleDeleteComment = async (commentId: string) => { try { - await api.delete(`/comment/${commentId}`); - setComments(comments.filter(comment => comment.id !== commentId)); + await api.delete(`/comment/${commentId}`) + setComments(comments.filter((comment) => comment.id !== commentId)) } catch (err) { - console.error('Failed to delete comment:', err); + console.error("Failed to delete comment:", err) } - }; + } const handleEditPost = () => { - setIsEditing(true); - }; + setIsEditing(true) + } const handleCancelEdit = () => { - setIsEditing(false); - setEditedTitle(post?.title || ''); - setEditedContent(post?.content || ''); - }; + setIsEditing(false) + setEditedTitle(post?.title || "") + setEditedContent(post?.content || "") + } const handleSaveEdit = async () => { try { const response = await api.put(`/post/${postId}`, { title: editedTitle, content: editedContent, - }); - setPost(response.data as PostModel); - setIsEditing(false); + }) + setPost(response.data as PostModel) + setIsEditing(false) } catch (err) { - console.error('Failed to update post:', err); + console.error("Failed to update post:", err) } - }; + } const handleDeletePost = async () => { try { - await api.delete(`/post/${postId}`); - navigate('/feed'); + await api.delete(`/post/${postId}`) + navigate("/feed") } catch (err) { - console.error('Failed to delete post:', err); + console.error("Failed to delete post:", err) } - }; + } const handleOpenDialog = () => { - setOpenDialog(true); - }; + setOpenDialog(true) + } const handleCloseDialog = () => { - setOpenDialog(false); - }; + setOpenDialog(false) + } const handleCommentToggle = () => { - setCommentsOpen(!commentsOpen); // Open or close the comments section - }; + setCommentsOpen(!commentsOpen) + } + + const handleSaveToggle = () => { + setIsSaved(!isSaved) + } + + const handleShare = () => { + if (navigator.share) { + navigator + .share({ + title: post?.title || "Shared post", + text: "Check out this post!", + url: window.location.href, + }) + .catch((error) => console.log("Error sharing", error)) + } else { + navigator.clipboard + .writeText(window.location.href) + .then(() => { + alert("Link copied to clipboard!") + }) + .catch((err) => { + console.error("Failed to copy link: ", err) + }) + } + } useEffect(() => { - loadPostDetails(); - }, [postId]); + loadPostDetails() + }, [postId]) - return ( - - - - navigate('/feed')}> - - - - Post Details - - {post?.owner === auth.userId && !isEditing && ( - <> - - - - - - - - )} - {isEditing && ( - <> - - - - - - - - )} + const renderSkeleton = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + {[1, 2].map((i) => ( + + + + + + + - {isLoading ? ( - - ) : error ? ( - {error} - ) : ( - post && ( - - - - {auth.username} + ))} + + ) + + return ( + + + + + navigate("/feed")} + sx={{ + mr: 1, + backgroundColor: alpha(theme.palette.primary.main, 0.1), + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.2), + }, + }} + > + + + + Post Details + + + {post?.owner === auth.userId && !isEditing && ( + + + + + + + + + + + - {isEditing ? ( - <> - setEditedTitle(e.target.value)} - sx={{ mb: 2 }} - /> - {editedContent !== null && ( - response.text()) - .then(imageId => { - // Construct the full image URL - const imageUrl = `${config.app.backend_url()}/resource/image/${imageId}`; - // Insert the uploaded image - editor.image.insert(imageUrl, null, null, editor.image.get()); - }) - .catch(error => { - console.error('Error uploading image:', error); - }); - - return false; // Prevent default upload - } - }, - pluginsEnabled: ["image", "link", "paragraphFormat"], + )} + + {isEditing && ( + + + + + + + + + + + + + )} + + + {isLoading ? ( + {renderSkeleton()} + ) : error ? ( + + + {error} + + + + ) : ( + post && ( + <> + + + - )} - - ) : ( - <> - - {post.title} - - - - - - )} - - - - - - - - - - - - - - - - Comments - - - {comments.map((comment) => ( - - - - - {comment.owner} - - {comment.owner === auth.userId && ( - handleDeleteComment(comment.id)}> - - - )} - + + + {post.ownerUsername || auth.username} + - + {post.createdAt && formatDate(post.createdAt)} + {post.updatedAt && post.updatedAt !== post.createdAt && ( + + )} - - ))} + + + {isEditing ? ( + <> + setEditedTitle(e.target.value)} + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + borderRadius: 2, + }, + }} + /> + {editedContent !== null && ( + + response.text()) + .then((imageId) => { + // Construct the full image URL + const imageUrl = `${config.app.backend_url()}/resource/image/${imageId}` + // Insert the uploaded image + editor.image.insert(imageUrl, null, null, editor.image.get()) + }) + .catch((error) => { + console.error("Error uploading image:", error) + }) + + return false // Prevent default upload + }, + }, + pluginsEnabled: ["image", "link", "paragraphFormat"], + }} + /> + + )} + + ) : ( + <> + + {post.title} + + + +
+ + + )} + + + + + + + + + + + + + + + + + + + + + + + + {isSaved ? : } + + + + + + + + + + - {commentsOpen && ( - + + + Comments ({comments.length}) + + + 5 ? "400px" : "auto", + overflowY: comments.length > 5 ? "auto" : "visible", + pr: comments.length > 5 ? 2 : 0, + "&::-webkit-scrollbar": { + width: "8px", }, - "contentChanged": function() { - const editor = this as any; - if (editor && editor.html) { - setNewComment(editor.html.get()); - } + "&::-webkit-scrollbar-track": { + background: "transparent", }, - "image.beforeUpload": function (files: File[]) { - const editor = this as any; - const file = files[0]; - - // Create FormData - const formData = new FormData(); - formData.append('file', file); - - // Upload the image - fetch(`${config.app.backend_url()}/resource/image`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${auth.accessToken}` + "&::-webkit-scrollbar-thumb": { + backgroundColor: alpha(theme.palette.text.secondary, 0.3), + borderRadius: "4px", + }, + "&::-webkit-scrollbar-thumb:hover": { + backgroundColor: alpha(theme.palette.text.secondary, 0.5), + }, + }} + > + {comments.length === 0 ? ( + + No comments yet. Be the first to comment! + + ) : ( + comments.map((comment, index) => ( + + + + + + {comment.owner} + + {comment.owner === auth.userId && ( + + handleDeleteComment(comment.id)} + sx={{ + color: theme.palette.text.secondary, + "&:hover": { + color: theme.palette.error.main, + backgroundColor: alpha(theme.palette.error.main, 0.1), + }, + }} + > + + + + )} + + +
+ + + + )) + )} + + + + + Add a comment + + + {newComment && response.text()) - .then(imageId => { - // Construct the full image URL - const imageUrl = `${config.app.backend_url()}/resource/image/${imageId}`; - // Insert the uploaded image - editor.image.insert(imageUrl, null, null, editor.image.get()); - }) - .catch(error => { - console.error('Error uploading image:', error); - }); - - return false; // Prevent default upload - } - }, - pluginsEnabled: ["image", "link", "paragraphFormat"] - }} - /> - )} - - - - ) - )} - + contentChanged: function () { + const editor = this as any + if (editor && editor.html) { + setNewComment(editor.html.get()) + } + }, + "image.beforeUpload": function (files: File[]) { + const editor = this as any + const file = files[0] + + // Create FormData + const formData = new FormData() + formData.append("file", file) + + // Upload the image + fetch(`${config.app.backend_url()}/resource/image`, { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + }, + body: formData, + }) + .then((response) => response.text()) + .then((imageId) => { + // Construct the full image URL + const imageUrl = `${config.app.backend_url()}/resource/image/${imageId}` + // Insert the uploaded image + editor.image.insert(imageUrl, null, null, editor.image.get()) + }) + .catch((error) => { + console.error("Error uploading image:", error) + }) + + return false // Prevent default upload + }, + }, + pluginsEnabled: ["image", "link"], + }} + /> + } + + + + + + + + + ) + )} + + - {"Are you sure you want to delete this post?"} + Delete Post - - This action cannot be undone. + + This action cannot be undone. Are you sure you want to delete this post? - - - - ); -}; + ) +} -export default PostDetails; \ No newline at end of file +export default PostDetails diff --git a/nextstep-frontend/src/pages/Profile.tsx b/nextstep-frontend/src/pages/Profile.tsx index 4d405e2..236867c 100644 --- a/nextstep-frontend/src/pages/Profile.tsx +++ b/nextstep-frontend/src/pages/Profile.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Container, Typography, Box, Button, Alert, TextField, Collapse } from '@mui/material'; import { ExpandMore, ExpandLess } from '@mui/icons-material'; -import {getUserAuth} from "../handlers/userAuth.ts"; +import {getUserAuth, setUserAuth} from "../handlers/userAuth.ts"; import api from "../serverApi.ts"; import {UserProfile} from "../models/UserProfile.ts"; import defaultProfileImage from '../../assets/defaultProfileImage.jpg'; @@ -36,6 +36,7 @@ const Profile: React.FC = () => { const response = await api.get(`/user/${getUserAuth().userId}`); const userProfile = response.data as UserProfile; // setProfile(userProfile); + setUserAuth({...auth, imageFilename: userProfile.imageFilename}) setUserName(userProfile.username); setEmail(userProfile.email); return userProfile; @@ -67,7 +68,6 @@ const Profile: React.FC = () => { }, }); - // setUserAuth({...auth, imageFilename: (response.data as LoginResponse).imageFilename}) if (response.status === 201) { setSuccess(true); setError(''); diff --git a/nextstep-frontend/src/pages/Resume.tsx b/nextstep-frontend/src/pages/Resume.tsx index 72e2fc2..51dde15 100644 --- a/nextstep-frontend/src/pages/Resume.tsx +++ b/nextstep-frontend/src/pages/Resume.tsx @@ -1,291 +1,505 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { - Box, - Button, - CircularProgress, - Typography, - TextField -} from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { config } from '../config'; -import api from '../serverApi'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import ScoreGauge from '../components/ScoreGauge'; -import './Resume.css'; +import type React from "react" +import { useState, useRef, useEffect } from "react" +import { + Box, + Button, + CircularProgress, + Typography, + TextField, + Alert, + Card, + CardContent, + Divider, + Snackbar, + useTheme, + alpha, + Stack, + Tooltip, +} from "@mui/material" +import { styled } from "@mui/material/styles" +import { config } from "../config" +import api from "../serverApi" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import ScoreGauge from "../components/ScoreGauge" +import { CloudUpload, CheckCircle, Description, Analytics, WorkOutline } from "@mui/icons-material" +import "./Resume.css" const UploadBox = styled(Box)(({ theme }) => ({ - border: '2px dashed #ccc', - borderRadius: '8px', - padding: '20px', - textAlign: 'center', - cursor: 'pointer', - '&:hover': { + border: `2px dashed ${alpha(theme.palette.primary.main, 0.3)}`, + borderRadius: "12px", + padding: "24px", + textAlign: "center", + cursor: "pointer", + transition: "all 0.2s ease", + backgroundColor: alpha(theme.palette.background.default, 0.5), + "&:hover": { borderColor: theme.palette.primary.main, + backgroundColor: alpha(theme.palette.background.default, 0.8), + transform: "translateY(-2px)", }, -})); +})) const FeedbackContainer = styled(Box)(({ theme }) => ({ - maxHeight: '60vh', - overflowY: 'auto', + maxHeight: "60vh", + overflowY: "auto", padding: theme.spacing(4), backgroundColor: theme.palette.background.paper, borderRadius: theme.shape.borderRadius, - textAlign: 'left', + textAlign: "left", color: theme.palette.text.primary, - '& pre': { - backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : theme.palette.grey[100], + boxShadow: `0 2px 8px ${alpha(theme.palette.common.black, 0.05)}`, + "& pre": { + backgroundColor: theme.palette.mode === "dark" ? theme.palette.grey[800] : theme.palette.grey[100], color: theme.palette.text.primary, padding: theme.spacing(2), borderRadius: theme.shape.borderRadius, - overflowX: 'auto', + overflowX: "auto", margin: theme.spacing(2, 0), }, - '& table': { - borderCollapse: 'collapse', - width: '100%', + "& table": { + borderCollapse: "collapse", + width: "100%", marginBottom: theme.spacing(2), }, - '& th, & td': { + "& th, & td": { border: `1px solid ${theme.palette.divider}`, padding: theme.spacing(1.5), - textAlign: 'left', + textAlign: "left", color: theme.palette.text.primary, }, - '& th': { - backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : theme.palette.grey[100], + "& th": { + backgroundColor: theme.palette.mode === "dark" ? theme.palette.grey[800] : theme.palette.grey[100], color: theme.palette.text.primary, }, - '& h1, & h2, & h3, & h4, & h5, & h6': { - textAlign: 'left', + "& h1, & h2, & h3, & h4, & h5, & h6": { + textAlign: "left", marginTop: theme.spacing(3), marginBottom: theme.spacing(2), color: theme.palette.text.primary, }, - '& p': { - textAlign: 'left', + "& p": { + textAlign: "left", marginBottom: theme.spacing(2), lineHeight: 1.6, color: theme.palette.text.primary, }, - '& ul, & ol': { + "& ul, & ol": { marginLeft: theme.spacing(4), marginBottom: theme.spacing(2), }, - '& li': { + "& li": { marginBottom: theme.spacing(1), color: theme.palette.text.primary, }, - '& blockquote': { + "& blockquote": { borderLeft: `4px solid ${theme.palette.primary.main}`, margin: theme.spacing(2, 0), padding: theme.spacing(2), - backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[900] : theme.palette.grey[50], + backgroundColor: theme.palette.mode === "dark" ? theme.palette.grey[900] : theme.palette.grey[50], borderRadius: theme.shape.borderRadius, color: theme.palette.text.primary, }, - '& code': { - backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : theme.palette.grey[100], + "& code": { + backgroundColor: theme.palette.mode === "dark" ? theme.palette.grey[800] : theme.palette.grey[100], color: theme.palette.text.primary, padding: theme.spacing(0.5, 1), borderRadius: theme.shape.borderRadius, - fontSize: '0.875em', + fontSize: "0.875em", }, - '& strong, & b': { + "& strong, & b": { color: theme.palette.text.primary, }, - '& em, & i': { + "& em, & i": { color: theme.palette.text.primary, }, -})); +})) const Resume: React.FC = () => { - const [file, setFile] = useState(null); - const [jobDescription, setJobDescription] = useState(''); - const [feedback, setFeedback] = useState(''); - const [score, setScore] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const fileInputRef = useRef(null); - const feedbackEndRef = useRef(null); + const theme = useTheme() + const [file, setFile] = useState(null) + const [jobDescription, setJobDescription] = useState("") + const [feedback, setFeedback] = useState("") + const [score, setScore] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const fileInputRef = useRef(null) + const feedbackEndRef = useRef(null) + const [fileName, setFileName] = useState("") + const [resumeId, setResumeId] = useState("") + const [updatingResume, setUpdatingResume] = useState(false) useEffect(() => { if (feedbackEndRef.current) { - feedbackEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + feedbackEndRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" }) } - }, [feedback]); + }, [feedback]) + + // Fetch resume data on mount + useEffect(() => { + const fetchResumeData = async () => { + try { + const response = await api.get("/resume") + if (response.data && response.data.parsedData) { + const parsedData = response.data.parsedData + setJobDescription(parsedData.jobDescription || parsedData.aboutMe || "") + setFileName(parsedData.fileName || "") + parsedData.feedback && setFeedback(parsedData.feedback) + parsedData.score && setScore(parsedData.score) + + // Store the resume ID for later use + if (response.data.rawContentLink) { + const id = response.data.rawContentLink.split("/").pop() || "" + setResumeId(id) + } + } + } catch (err) { + console.error("Failed to fetch resume data:", err) + } + } + fetchResumeData() + }, []) const uploadResume = async (formData: FormData) => { - const response = await api.post('/resource/resume', formData, { + const response = await api.post("/resource/resume", formData, { headers: { - 'Content-Type': 'multipart/form-data', + "Content-Type": "multipart/form-data", }, - }); - return response.data; - }; + }) + return response.data + } const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { - setFile(event.target.files[0]); - setError(''); + setFile(event.target.files[0]) + setFileName(event.target.files[0].name) + setError("") } - }; + } const handleUploadClick = () => { - fileInputRef.current?.click(); - }; + fileInputRef.current?.click() + } const handleSubmit = async () => { if (!file) { - setError('Please select a file'); - return; + // Check if we can use existing resume data + try { + const resume = await api.get("/resume") + if (!resume.data || !resume.data.rawContentLink) { + setError("Please select a file") + return + } + } catch (err) { + setError("Please select a file") + return + } } - setLoading(true); - setFeedback(''); - setScore(null); - setError(''); + setLoading(true) + setFeedback("") + setScore(null) + setError("") try { - const formData = new FormData(); - formData.append('file', file); + let filename = "" + if (file) { + const formData = new FormData() + formData.append("file", file) + filename = await uploadResume(formData) + setResumeId(filename) + } else { + const resume = await api.get("/resume") + filename = resume.data.rawContentLink.split("/").pop() || "" + setResumeId(filename) + } - const filename = await uploadResume(formData); + const token = localStorage.getItem(config.localStorageKeys.userAuth) + ? JSON.parse(localStorage.getItem(config.localStorageKeys.userAuth)!).accessToken + : "" - const token = localStorage.getItem(config.localStorageKeys.userAuth) - ? JSON.parse(localStorage.getItem(config.localStorageKeys.userAuth)!).accessToken - : ''; - const eventSource = new EventSource( - `${config.app.backend_url()}/resume/streamScore/${filename}?jobDescription=${encodeURIComponent(jobDescription)}&accessToken=${encodeURIComponent(token)}` - ); + `${config.app.backend_url()}/resume/streamScore/${filename}?jobDescription=${encodeURIComponent(jobDescription)}&accessToken=${encodeURIComponent(token)}`, + ) eventSource.onmessage = (event) => { try { - const data = JSON.parse(event.data); - + const data = JSON.parse(event.data) + if (data.done) { - setScore(data.score); - eventSource.close(); - setLoading(false); + setScore(data.score) + eventSource.close() + setLoading(false) + data.fullText && setFeedback(data.fullText) } else if (data.chunk) { - setFeedback(prev => prev + data.chunk); + setFeedback((prev) => prev + data.chunk) } } catch (e) { - console.error('Error parsing event data:', e); + console.error("Error parsing event data:", e) } - }; + } eventSource.onerror = (error) => { - console.error('EventSource failed:', error); - setError('Failed to analyze resume'); - eventSource.close(); - setLoading(false); - }; - + console.error("EventSource failed:", error) + setError("Failed to analyze resume") + eventSource.close() + setLoading(false) + } } catch (err: any) { - if (err.response && err.response.status === 400 && - err.response.data && err.response.data && - err.response.data.message) { - setError(err.response.data.message); + if ( + err.response && + err.response.status === 400 && + err.response.data && + err.response.data && + err.response.data.message + ) { + setError(err.response.data.message) } else { - setError(err instanceof Error ? err.message : 'An error occurred'); + setError(err instanceof Error ? err.message : "An error occurred") + } + setLoading(false) + } + } + + const handleUseResumeFile = async () => { + if (!file && !resumeId) { + setError("No resume file available to use") + return + } + + setUpdatingResume(true) + setError("") + + try { + let filename = resumeId + + // If there's a new file, upload it first + if (file) { + const formData = new FormData() + formData.append("file", file) + filename = await uploadResume(formData) + setResumeId(filename) } - setLoading(false); + + // Update the resume in the system + await api.post("/resume/parseResume", { + resumefileName: filename, + originfilename: fileName, + }) + + setSuccess("Resume updated successfully!") + setTimeout(() => setSuccess(""), 1000) + } catch (err) { + console.error("Failed to update resume:", err) + setError("Failed to update resume. Please try again.") + } finally { + setUpdatingResume(false) } - }; + } return ( - - + - Score your resume - - - - setJobDescription(e.target.value)} - sx={{ - mb: 2, - '& .MuiOutlinedInput-root': { - backgroundColor: 'background.paper' - } - }} - /> - - - - - {file ? ( - {file.name} - ) : ( - Click to upload your resume - )} - + + + + Resume Analyzer + + + + - {error && ( - - {error} + + Upload your resume and a job description to get personalized feedback and a match score. + + + + + + Job Description - )} - - - - - - {loading && } + setJobDescription(e.target.value)} + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + backgroundColor: alpha(theme.palette.background.default, 0.5), + }, + }} + /> - {feedback && ( - - - Analysis Feedback: + + + Resume - - - {feedback} - -
- + + + + + {fileName ? ( + + + {fileName} + + ) : ( + + + Click to upload your resume + + Supported formats: PDF, DOC, DOCX + + + )} + + + {error && ( + + {error} + + )} + + + + + + + + + + + + {loading && ( + + )} + {feedback && ( + + + + Analysis Feedback + + + + {feedback} +
+ + + + )} + {score !== null && ( - - - + + + Resume Match Score + + + + + + + + {score >= 80 + ? "Excellent match! Your resume is well-aligned with this job description." + : score >= 60 + ? "Good match. Consider the feedback above to improve your resume further." + : "Your resume needs some improvements to better match this job description."} + + )} + + setSuccess("")} + message={success} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> - ); -}; + ) +} -export default Resume; \ No newline at end of file +export default Resume diff --git a/nextstep-frontend/src/theme.ts b/nextstep-frontend/src/theme.ts index d23a2ef..32cc029 100644 --- a/nextstep-frontend/src/theme.ts +++ b/nextstep-frontend/src/theme.ts @@ -1,4 +1,4 @@ -import { createTheme, ThemeOptions } from '@mui/material'; +import { createTheme, type ThemeOptions } from "@mui/material" // Common theme settings const commonSettings: ThemeOptions = { @@ -6,19 +6,19 @@ const commonSettings: ThemeOptions = { fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', h1: { fontWeight: 800, - letterSpacing: '-0.02em', + letterSpacing: "-0.02em", }, h2: { fontWeight: 800, - letterSpacing: '-0.02em', + letterSpacing: "-0.02em", }, h3: { fontWeight: 700, - letterSpacing: '-0.01em', + letterSpacing: "-0.01em", }, h4: { fontWeight: 600, - letterSpacing: '-0.01em', + letterSpacing: "-0.01em", }, h5: { fontWeight: 600, @@ -34,19 +34,19 @@ const commonSettings: ThemeOptions = { MuiButton: { styleOverrides: { root: { - textTransform: 'none', + textTransform: "none", fontWeight: 600, - borderRadius: '50px', - padding: '10px 24px', - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover': { - transform: 'translateY(-2px)', + borderRadius: "50px", + padding: "10px 24px", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + transform: "translateY(-2px)", }, }, contained: { - boxShadow: '0 4px 14px rgba(0, 0, 0, 0.1)', - '&:hover': { - boxShadow: '0 6px 20px rgba(0, 0, 0, 0.15)', + boxShadow: "0 4px 14px rgba(0, 0, 0, 0.1)", + "&:hover": { + boxShadow: "0 6px 20px rgba(0, 0, 0, 0.15)", }, }, }, @@ -55,11 +55,11 @@ const commonSettings: ThemeOptions = { styleOverrides: { root: { borderRadius: 16, - boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)', - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover': { - transform: 'translateY(-4px)', - boxShadow: '0 8px 30px rgba(0, 0, 0, 0.12)', + boxShadow: "0 4px 20px rgba(0, 0, 0, 0.08)", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + transform: "translateY(-4px)", + boxShadow: "0 8px 30px rgba(0, 0, 0, 0.12)", }, }, }, @@ -68,71 +68,68 @@ const commonSettings: ThemeOptions = { styleOverrides: { root: { borderRadius: 16, - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", }, }, }, MuiIconButton: { styleOverrides: { root: { - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover': { - transform: 'scale(1.1)', + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + transform: "scale(1.1)", }, }, }, }, }, -}; +} -// Light theme -export const lightTheme = createTheme({ +// Update the dark theme with more sophisticated colors and better contrast +export const darkTheme = createTheme({ ...commonSettings, palette: { - mode: 'light', + mode: "dark", primary: { - main: '#0984E3', - light: '#74B9FF', - dark: '#0652DD', + main: "#60a5fa", // Brighter blue that stands out better on dark backgrounds + light: "#93c5fd", + dark: "#3b82f6", + contrastText: "#ffffff", }, secondary: { - main: '#00B894', - light: '#55EFC4', - dark: '#00A884', + main: "#10b981", // Vibrant teal/green + light: "#34d399", + dark: "#059669", + contrastText: "#ffffff", }, background: { - default: '#FFFFFF', - paper: '#F8F9FA', + default: "#111827", // Deeper, richer dark background + paper: "#1f2937", // Slightly lighter than default for cards/surfaces }, text: { - primary: '#2D3436', - secondary: '#636E72', + primary: "#f3f4f6", // Light gray instead of pure white for better readability + secondary: "#d1d5db", // Medium gray for secondary text }, - }, -}); - -// Dark theme -export const darkTheme = createTheme({ - ...commonSettings, - palette: { - mode: 'dark', - primary: { - main: '#0984E3', - light: '#74B9FF', - dark: '#0652DD', + divider: "rgba(255, 255, 255, 0.08)", // Subtle dividers + error: { + main: "#ef4444", + light: "#f87171", + dark: "#dc2626", }, - secondary: { - main: '#00B894', - light: '#55EFC4', - dark: '#00A884', + warning: { + main: "#f59e0b", + light: "#fbbf24", + dark: "#d97706", }, - background: { - default: '#1A1A1A', - paper: '#2D2D2D', + info: { + main: "#3b82f6", + light: "#60a5fa", + dark: "#2563eb", }, - text: { - primary: '#FFFFFF', - secondary: '#E0E0E0', + success: { + main: "#10b981", + light: "#34d399", + dark: "#059669", }, }, components: { @@ -140,25 +137,32 @@ export const darkTheme = createTheme({ MuiButton: { styleOverrides: { root: { - textTransform: 'none', + textTransform: "none", fontWeight: 600, - borderRadius: '50px', - padding: '10px 24px', - transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover': { - transform: 'translateY(-2px)', + borderRadius: "8px", + padding: "10px 24px", + transition: "all 0.2s ease-in-out", + "&:hover": { + transform: "translateY(-2px)", + boxShadow: "0 5px 15px rgba(0, 0, 0, 0.3)", }, }, contained: { - boxShadow: '0 4px 14px rgba(0, 0, 0, 0.2)', - '&:hover': { - boxShadow: '0 6px 20px rgba(0, 0, 0, 0.25)', + boxShadow: "0 4px 6px rgba(0, 0, 0, 0.2)", + "&:hover": { + boxShadow: "0 6px 12px rgba(0, 0, 0, 0.3)", }, }, outlined: { - borderColor: 'rgba(255, 255, 255, 0.23)', - '&:hover': { - borderColor: 'rgba(255, 255, 255, 0.5)', + borderColor: "rgba(255, 255, 255, 0.23)", + "&:hover": { + borderColor: "rgba(255, 255, 255, 0.5)", + backgroundColor: "rgba(255, 255, 255, 0.05)", + }, + }, + text: { + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.05)", }, }, }, @@ -166,96 +170,329 @@ export const darkTheme = createTheme({ MuiCard: { styleOverrides: { root: { - boxShadow: '0 4px 20px rgba(0, 0, 0, 0.2)', - '&:hover': { - transform: 'translateY(-4px)', - boxShadow: '0 8px 30px rgba(0, 0, 0, 0.3)', + borderRadius: 12, + boxShadow: "0 4px 20px rgba(0, 0, 0, 0.25)", + backdropFilter: "blur(10px)", + backgroundColor: "rgba(31, 41, 55, 0.95)", // Slightly transparent + backgroundImage: "linear-gradient(to bottom right, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0))", + border: "1px solid rgba(255, 255, 255, 0.05)", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + transform: "translateY(-4px)", + boxShadow: "0 8px 30px rgba(0, 0, 0, 0.35)", }, }, }, }, - MuiTypography: { + MuiPaper: { styleOverrides: { root: { - color: '#FFFFFF', + borderRadius: 12, + backgroundImage: "linear-gradient(to bottom right, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0))", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", }, - body1: { - color: '#E0E0E0', + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + boxShadow: "0 2px 10px rgba(0, 0, 0, 0.3)", + backdropFilter: "blur(10px)", + backgroundColor: "rgba(17, 24, 39, 0.8)", // Semi-transparent }, - body2: { - color: '#E0E0E0', + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundColor: "#1a2234", // Slightly different than main background + backgroundImage: "linear-gradient(to bottom, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0))", + borderRight: "1px solid rgba(255, 255, 255, 0.05)", }, - h1: { - color: '#FFFFFF', + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + color: "#d1d5db", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.08)", + transform: "scale(1.1)", + }, + "&.Mui-selected": { + backgroundColor: "rgba(96, 165, 250, 0.15)", + color: "#60a5fa", + "&:hover": { + backgroundColor: "rgba(96, 165, 250, 0.25)", + }, + }, }, - h2: { - color: '#FFFFFF', + }, + }, + MuiInputBase: { + styleOverrides: { + root: { + backgroundColor: "rgba(31, 41, 55, 0.8)", + borderRadius: 8, + transition: "all 0.2s ease", + "&.Mui-focused": { + boxShadow: "0 0 0 2px rgba(96, 165, 250, 0.3)", + }, }, - h3: { - color: '#FFFFFF', + input: { + "&::placeholder": { + color: "rgba(209, 213, 219, 0.5)", + opacity: 1, + }, }, - h4: { - color: '#FFFFFF', + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "rgba(255, 255, 255, 0.1)", + transition: "all 0.2s ease", + }, + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: "rgba(255, 255, 255, 0.2)", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: "#60a5fa", + borderWidth: 2, + }, }, - h5: { - color: '#E0E0E0', + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 8, + margin: "4px 0", + "&.Mui-selected": { + backgroundColor: "rgba(96, 165, 250, 0.15)", + "&:hover": { + backgroundColor: "rgba(96, 165, 250, 0.25)", + }, + "& .MuiListItemIcon-root": { + color: "#60a5fa", + }, + "& .MuiListItemText-primary": { + color: "#f3f4f6", + fontWeight: 600, + }, + }, + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.05)", + }, }, - h6: { - color: '#E0E0E0', + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: "rgba(255, 255, 255, 0.08)", }, }, }, - MuiIconButton: { + MuiChip: { styleOverrides: { root: { - color: 'inherit', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 8, + backgroundColor: "rgba(255, 255, 255, 0.05)", + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.1)", }, - '&.Mui-selected': { - backgroundColor: 'rgba(9, 132, 227, 0.2)', - color: '#0984E3', - '&:hover': { - backgroundColor: 'rgba(9, 132, 227, 0.3)', - }, + }, + deleteIcon: { + color: "rgba(255, 255, 255, 0.5)", + "&:hover": { + color: "rgba(255, 255, 255, 0.8)", }, - '&.MuiIconButton-colorPrimary': { - color: '#0984E3', - '&:hover': { - backgroundColor: 'rgba(9, 132, 227, 0.1)', - }, + }, + }, + }, + MuiAvatar: { + styleOverrides: { + root: { + border: "2px solid rgba(255, 255, 255, 0.1)", + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + backgroundColor: "rgba(17, 24, 39, 0.95)", + backdropFilter: "blur(4px)", + border: "1px solid rgba(255, 255, 255, 0.1)", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", + borderRadius: 8, + padding: "8px 12px", + fontSize: "0.75rem", + }, + }, + }, + }, +}) + +// Also update the light theme for consistency +export const lightTheme = createTheme({ + ...commonSettings, + palette: { + mode: "light", + primary: { + main: "#3b82f6", // Vibrant blue + light: "#60a5fa", + dark: "#2563eb", + contrastText: "#ffffff", + }, + secondary: { + main: "#10b981", // Vibrant teal/green + light: "#34d399", + dark: "#059669", + contrastText: "#ffffff", + }, + background: { + default: "#f9fafb", + paper: "#ffffff", + }, + text: { + primary: "#111827", + secondary: "#4b5563", + }, + divider: "rgba(0, 0, 0, 0.08)", + error: { + main: "#ef4444", + light: "#f87171", + dark: "#dc2626", + }, + warning: { + main: "#f59e0b", + light: "#fbbf24", + dark: "#d97706", + }, + info: { + main: "#3b82f6", + light: "#60a5fa", + dark: "#2563eb", + }, + success: { + main: "#10b981", + light: "#34d399", + dark: "#059669", + }, + }, + components: { + ...commonSettings.components, + MuiButton: { + styleOverrides: { + root: { + textTransform: "none", + fontWeight: 600, + borderRadius: "8px", + padding: "10px 24px", + transition: "all 0.2s ease-in-out", + "&:hover": { + transform: "translateY(-2px)", + boxShadow: "0 5px 15px rgba(0, 0, 0, 0.1)", }, - '&.MuiIconButton-colorDefault': { - color: 'rgba(255, 255, 255, 0.7)', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.1)', - }, + }, + contained: { + boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", + "&:hover": { + boxShadow: "0 4px 10px rgba(0, 0, 0, 0.15)", }, }, }, }, - MuiDivider: { + MuiCard: { styleOverrides: { root: { - borderColor: 'rgba(255, 255, 255, 0.12)', + borderRadius: 12, + boxShadow: "0 4px 20px rgba(0, 0, 0, 0.08)", + border: "1px solid rgba(0, 0, 0, 0.03)", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + transform: "translateY(-4px)", + boxShadow: "0 8px 30px rgba(0, 0, 0, 0.12)", + }, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + borderRadius: 12, + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + }, + }, + }, + MuiInputBase: { + styleOverrides: { + root: { + borderRadius: 8, + transition: "all 0.2s ease", + "&.Mui-focused": { + boxShadow: "0 0 0 2px rgba(59, 130, 246, 0.2)", + }, + }, + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "rgba(0, 0, 0, 0.1)", + transition: "all 0.2s ease", + }, + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: "rgba(0, 0, 0, 0.2)", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: "#3b82f6", + borderWidth: 2, + }, }, }, }, MuiListItemButton: { styleOverrides: { root: { - '&.Mui-selected': { - backgroundColor: 'rgba(9, 132, 227, 0.2)', - color: '#0984E3', - '&:hover': { - backgroundColor: 'rgba(9, 132, 227, 0.3)', + borderRadius: 8, + margin: "4px 0", + "&.Mui-selected": { + backgroundColor: "rgba(59, 130, 246, 0.08)", + "&:hover": { + backgroundColor: "rgba(59, 130, 246, 0.12)", }, - '& .MuiListItemIcon-root': { - color: '#0984E3', + "& .MuiListItemIcon-root": { + color: "#3b82f6", }, + "& .MuiListItemText-primary": { + color: "#111827", + fontWeight: 600, + }, + }, + "&:hover": { + backgroundColor: "rgba(0, 0, 0, 0.03)", }, }, }, }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 8, + }, + }, + }, + MuiAvatar: { + styleOverrides: { + root: { + border: "2px solid rgba(0, 0, 0, 0.05)", + }, + }, + }, }, -}); \ No newline at end of file +})